feat: 解决了一些问题

This commit is contained in:
cc
2026-01-28 23:04:29 +08:00
parent a215886015
commit 77689ec528
19 changed files with 2032 additions and 454 deletions

View File

@@ -1,3 +1,4 @@
import './preload-env'
import { app, BrowserWindow, ipcMain, nativeTheme, session } from 'electron' import { app, BrowserWindow, ipcMain, nativeTheme, session } from 'electron'
import { Worker } from 'worker_threads' import { Worker } from 'worker_threads'
import { join, dirname } from 'path' import { join, dirname } from 'path'
@@ -451,7 +452,7 @@ function registerIpcHandlers() {
// 监听下载进度 // 监听下载进度
autoUpdater.on('download-progress', (progress) => { autoUpdater.on('download-progress', (progress) => {
win?.webContents.send('app:downloadProgress', progress.percent) win?.webContents.send('app:downloadProgress', progress)
}) })
// 下载完成后自动安装 // 下载完成后自动安装
@@ -682,6 +683,14 @@ function registerIpcHandlers() {
return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime) return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
}) })
ipcMain.handle('sns:debugResource', async (_, url: string) => {
return snsService.debugResource(url)
})
ipcMain.handle('sns:proxyImage', async (_, url: string) => {
return snsService.proxyImage(url)
})
// 私聊克隆 // 私聊克隆

39
electron/preload-env.ts Normal file
View File

@@ -0,0 +1,39 @@
import { join, dirname } from 'path'
/**
* 强制将本地资源目录添加到 PATH 最前端,确保优先加载本地 DLL
* 解决系统中存在冲突版本的 DLL 导致的应用崩溃问题
*/
function enforceLocalDllPriority() {
const isDev = !!process.env.VITE_DEV_SERVER_URL
const sep = process.platform === 'win32' ? ';' : ':'
let possiblePaths: string[] = []
if (isDev) {
// 开发环境
possiblePaths.push(join(process.cwd(), 'resources'))
} else {
// 生产环境
possiblePaths.push(dirname(process.execPath))
if (process.resourcesPath) {
possiblePaths.push(process.resourcesPath)
}
}
const dllPaths = possiblePaths.join(sep)
if (process.env.PATH) {
process.env.PATH = dllPaths + sep + process.env.PATH
} else {
process.env.PATH = dllPaths
}
console.log('[WeFlow] Environment PATH updated to enforce local DLL priority:', dllPaths)
}
try {
enforceLocalDllPriority()
} catch (e) {
console.error('[WeFlow] Failed to enforce local DLL priority:', e)
}

View File

@@ -29,7 +29,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
getVersion: () => ipcRenderer.invoke('app:getVersion'), getVersion: () => ipcRenderer.invoke('app:getVersion'),
checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'), checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'),
downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'), downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'),
onDownloadProgress: (callback: (progress: number) => void) => { onDownloadProgress: (callback: (progress: any) => void) => {
ipcRenderer.on('app:downloadProgress', (_, progress) => callback(progress)) ipcRenderer.on('app:downloadProgress', (_, progress) => callback(progress))
return () => ipcRenderer.removeAllListeners('app:downloadProgress') return () => ipcRenderer.removeAllListeners('app:downloadProgress')
}, },
@@ -214,6 +214,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
// 朋友圈 // 朋友圈
sns: { sns: {
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime) ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
proxyImage: (url: string) => ipcRenderer.invoke('sns:proxyImage', url)
} }
}) })

View File

@@ -970,7 +970,7 @@ class ChatService {
const title = this.extractXmlValue(content, 'title') const title = this.extractXmlValue(content, 'title')
return title || '[引用消息]' return title || '[引用消息]'
case 266287972401: case 266287972401:
return '[拍一拍]' return this.cleanPatMessage(content)
case 81604378673: case 81604378673:
return '[聊天记录]' return '[聊天记录]'
case 8594229559345: case 8594229559345:
@@ -1659,6 +1659,37 @@ class ChatService {
} }
} }
/**
* 清理拍一拍消息
* 格式示例: 我拍了拍 "梨绒" ງ໐໐໓ ຖiງht620000wxid_...
*/
private cleanPatMessage(content: string): string {
if (!content) return '[拍一拍]'
// 1. 尝试匹配标准的 "A拍了拍B" 格式
// 这里的正则比较宽泛,为了兼容不同的语言环境
const match = /^(.+?拍了拍.+?)(?:[\r\n]|$|ງ|wxid_)/.exec(content)
if (match) {
return `[拍一拍] ${match[1].trim()}`
}
// 2. 如果匹配失败,尝试清理掉疑似的 garbage (wxid, 乱码)
let cleaned = content.replace(/wxid_[a-zA-Z0-9_-]+/g, '') // 移除 wxid
cleaned = cleaned.replace(/[ງ໓ຖiht]+/g, ' ') // 移除已知的乱码字符
cleaned = cleaned.replace(/\d{6,}/g, '') // 移除长数字
cleaned = cleaned.replace(/\s+/g, ' ').trim() // 清理空格
// 移除不可见字符
cleaned = this.cleanUtf16(cleaned)
// 如果清理后还有内容,返回
if (cleaned && cleaned.length > 1 && !cleaned.includes('xml')) {
return `[拍一拍] ${cleaned}`
}
return '[拍一拍]'
}
/** /**
* 解码消息内容(处理 BLOB 和压缩数据) * 解码消息内容(处理 BLOB 和压缩数据)
*/ */
@@ -2323,7 +2354,7 @@ class ChatService {
/** /**
* getVoiceData (绕过WCDB的buggy getVoiceData直接用execQuery读取) * getVoiceData (绕过WCDB的buggy getVoiceData直接用execQuery读取)
*/ */
async getVoiceData(sessionId: string, msgId: string, createTime?: number, serverId?: string | number): Promise<{ success: boolean; data?: string; error?: string }> { async getVoiceData(sessionId: string, msgId: string, createTime?: number, serverId?: string | number, senderWxidOpt?: string): Promise<{ success: boolean; data?: string; error?: string }> {
const startTime = Date.now() const startTime = Date.now()
try { try {
const localId = parseInt(msgId, 10) const localId = parseInt(msgId, 10)
@@ -2332,7 +2363,7 @@ class ChatService {
} }
let msgCreateTime = createTime let msgCreateTime = createTime
let senderWxid: string | null = null let senderWxid: string | null = senderWxidOpt || null
// 如果前端没传 createTime才需要查询消息这个很慢 // 如果前端没传 createTime才需要查询消息这个很慢
if (!msgCreateTime) { if (!msgCreateTime) {
@@ -2403,7 +2434,7 @@ class ChatService {
console.log(`[Voice] getVoiceDataFromMediaDb: ${t4 - t3}ms`) console.log(`[Voice] getVoiceDataFromMediaDb: ${t4 - t3}ms`)
if (!silkData) { if (!silkData) {
return { success: false, error: '未找到语音数据' } return { success: false, error: '未找到语音数据 (请确保已在微信中播放过该语音)' }
} }
const t5 = Date.now() const t5 = Date.now()
@@ -2471,11 +2502,20 @@ class ChatService {
const t2 = Date.now() const t2 = Date.now()
console.log(`[Voice] listMediaDbs: ${t2 - t1}ms`) console.log(`[Voice] listMediaDbs: ${t2 - t1}ms`)
if (!mediaDbsResult.success || !mediaDbsResult.data || mediaDbsResult.data.length === 0) { let files = mediaDbsResult.success && mediaDbsResult.data ? (mediaDbsResult.data as string[]) : []
// Fallback: 如果 WCDB DLL 没找到,手动查找
if (files.length === 0) {
console.warn('[Voice] listMediaDbs returned empty, trying manual search')
files = await this.findMediaDbsManually()
}
if (files.length === 0) {
console.error('[Voice] No media DBs found')
return null return null
} }
mediaDbFiles = mediaDbsResult.data as string[] mediaDbFiles = files
this.mediaDbsCache = mediaDbFiles // 永久缓存 this.mediaDbsCache = mediaDbFiles // 永久缓存
} }
@@ -2854,7 +2894,8 @@ class ChatService {
sessionId: string, sessionId: string,
msgId: string, msgId: string,
createTime?: number, createTime?: number,
onPartial?: (text: string) => void onPartial?: (text: string) => void,
senderWxid?: string
): Promise<{ success: boolean; transcript?: string; error?: string }> { ): Promise<{ success: boolean; transcript?: string; error?: string }> {
const startTime = Date.now() const startTime = Date.now()
console.log(`[Transcribe] 开始转写: sessionId=${sessionId}, msgId=${msgId}, createTime=${createTime}`) console.log(`[Transcribe] 开始转写: sessionId=${sessionId}, msgId=${msgId}, createTime=${createTime}`)
@@ -2926,7 +2967,7 @@ class ChatService {
console.log(`[Transcribe] WAV缓存未命中调用 getVoiceData`) console.log(`[Transcribe] WAV缓存未命中调用 getVoiceData`)
const t3 = Date.now() const t3 = Date.now()
// 调用 getVoiceData 获取并解码 // 调用 getVoiceData 获取并解码
const voiceResult = await this.getVoiceData(sessionId, msgId, msgCreateTime, serverId) const voiceResult = await this.getVoiceData(sessionId, msgId, msgCreateTime, serverId, senderWxid)
const t4 = Date.now() const t4 = Date.now()
console.log(`[Transcribe] getVoiceData: ${t4 - t3}ms, success=${voiceResult.success}`) console.log(`[Transcribe] getVoiceData: ${t4 - t3}ms, success=${voiceResult.success}`)

View File

@@ -10,6 +10,7 @@ import { wcdbService } from './wcdbService'
import { imageDecryptService } from './imageDecryptService' import { imageDecryptService } from './imageDecryptService'
import { chatService } from './chatService' import { chatService } from './chatService'
import { videoService } from './videoService' import { videoService } from './videoService'
import { voiceTranscribeService } from './voiceTranscribeService'
import { EXPORT_HTML_STYLES } from './exportHtmlStyles' import { EXPORT_HTML_STYLES } from './exportHtmlStyles'
// ChatLab 格式类型定义 // ChatLab 格式类型定义
@@ -1032,15 +1033,15 @@ class ExportService {
/** /**
* 转写语音为文字 * 转写语音为文字
*/ */
private async transcribeVoice(sessionId: string, msgId: string): Promise<string> { private async transcribeVoice(sessionId: string, msgId: string, createTime: number, senderWxid: string | null): Promise<string> {
try { try {
const transcript = await chatService.getVoiceTranscript(sessionId, msgId) const transcript = await chatService.getVoiceTranscript(sessionId, msgId, createTime, undefined, senderWxid || undefined)
if (transcript.success && transcript.transcript) { if (transcript.success && transcript.transcript) {
return `[语音转文字] ${transcript.transcript}` return `[语音转文字] ${transcript.transcript}`
} }
return '[语音消息 - 转文字失败]' return `[语音消息 - 转文字失败: ${transcript.error || '未知错误'}]`
} catch (e) { } catch (e) {
return '[语音消息 - 转文字失败]' return `[语音消息 - 转文字失败: ${String(e)}]`
} }
} }
@@ -1655,6 +1656,10 @@ class ExportService {
phase: 'preparing' phase: 'preparing'
}) })
if (options.exportVoiceAsText) {
await this.ensureVoiceModel(onProgress)
}
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
const allMessages = collected.rows const allMessages = collected.rows
if (isGroup) { if (isGroup) {
@@ -1719,7 +1724,7 @@ class ExportService {
// 并行转写语音,限制 4 个并发(转写比较耗资源) // 并行转写语音,限制 4 个并发(转写比较耗资源)
const VOICE_CONCURRENCY = 4 const VOICE_CONCURRENCY = 4
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
const transcript = await this.transcribeVoice(sessionId, String(msg.localId)) const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
voiceTranscriptMap.set(msg.localId, transcript) voiceTranscriptMap.set(msg.localId, transcript)
}) })
} }
@@ -1849,6 +1854,10 @@ class ExportService {
phase: 'preparing' phase: 'preparing'
}) })
if (options.exportVoiceAsText) {
await this.ensureVoiceModel(onProgress)
}
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
@@ -1904,7 +1913,7 @@ class ExportService {
const VOICE_CONCURRENCY = 4 const VOICE_CONCURRENCY = 4
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
const transcript = await this.transcribeVoice(sessionId, String(msg.localId)) const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
voiceTranscriptMap.set(msg.localId, transcript) voiceTranscriptMap.set(msg.localId, transcript)
}) })
} }
@@ -2088,6 +2097,10 @@ class ExportService {
phase: 'preparing' phase: 'preparing'
}) })
if (options.exportVoiceAsText) {
await this.ensureVoiceModel(onProgress)
}
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
@@ -2202,11 +2215,11 @@ class ExportService {
} }
// 预加载群昵称 (仅群聊且完整列模式) // 预加载群昵称 (仅群聊且完整列模式)
console.log('🔍 预加载群昵称检查: isGroup=', isGroup, 'useCompactColumns=', useCompactColumns, 'sessionId=', sessionId) console.log('预加载群昵称检查: isGroup=', isGroup, 'useCompactColumns=', useCompactColumns, 'sessionId=', sessionId)
const groupNicknamesMap = (isGroup && !useCompactColumns) const groupNicknamesMap = (isGroup && !useCompactColumns)
? await this.getGroupNicknamesForRoom(sessionId) ? await this.getGroupNicknamesForRoom(sessionId)
: new Map<string, string>() : new Map<string, string>()
console.log('🔍 群昵称Map大小:', groupNicknamesMap.size) console.log('群昵称Map大小:', groupNicknamesMap.size)
// 填充数据 // 填充数据
@@ -2267,7 +2280,7 @@ class ExportService {
const VOICE_CONCURRENCY = 4 const VOICE_CONCURRENCY = 4
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
const transcript = await this.transcribeVoice(sessionId, String(msg.localId)) const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
voiceTranscriptMap.set(msg.localId, transcript) voiceTranscriptMap.set(msg.localId, transcript)
}) })
} }
@@ -2417,6 +2430,41 @@ class ExportService {
} }
} }
/**
* 确保语音转写模型已下载
*/
private async ensureVoiceModel(onProgress?: (progress: ExportProgress) => void): Promise<boolean> {
try {
const status = await voiceTranscribeService.getModelStatus()
if (status.success && status.exists) {
return true
}
onProgress?.({
current: 0,
total: 100,
currentSession: '正在下载 AI 模型',
phase: 'preparing'
})
const downloadResult = await voiceTranscribeService.downloadModel((progress: any) => {
if (progress.percent !== undefined) {
onProgress?.({
current: progress.percent,
total: 100,
currentSession: `正在下载 AI 模型 (${progress.percent.toFixed(0)}%)`,
phase: 'preparing'
})
}
})
return downloadResult.success
} catch (e) {
console.error('Auto download model failed:', e)
return false
}
}
/** /**
* 导出单个会话为 TXT 格式(默认与 Excel 精简列一致) * 导出单个会话为 TXT 格式(默认与 Excel 精简列一致)
*/ */
@@ -2442,6 +2490,10 @@ class ExportService {
phase: 'preparing' phase: 'preparing'
}) })
if (options.exportVoiceAsText) {
await this.ensureVoiceModel(onProgress)
}
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime) const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
@@ -2495,7 +2547,7 @@ class ExportService {
const VOICE_CONCURRENCY = 4 const VOICE_CONCURRENCY = 4
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
const transcript = await this.transcribeVoice(sessionId, String(msg.localId)) const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
voiceTranscriptMap.set(msg.localId, transcript) voiceTranscriptMap.set(msg.localId, transcript)
}) })
} }
@@ -2613,6 +2665,10 @@ class ExportService {
phase: 'preparing' phase: 'preparing'
}) })
if (options.exportVoiceAsText) {
await this.ensureVoiceModel(onProgress)
}
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
if (isGroup) { if (isGroup) {
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true) await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
@@ -2673,7 +2729,7 @@ class ExportService {
const VOICE_CONCURRENCY = 4 const VOICE_CONCURRENCY = 4
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
const transcript = await this.transcribeVoice(sessionId, String(msg.localId)) const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
voiceTranscriptMap.set(msg.localId, transcript) voiceTranscriptMap.set(msg.localId, transcript)
}) })
} }

View File

@@ -2,6 +2,25 @@ import { wcdbService } from './wcdbService'
import { ConfigService } from './config' import { ConfigService } from './config'
import { ContactCacheService } from './contactCacheService' import { ContactCacheService } from './contactCacheService'
export interface SnsLivePhoto {
url: string
thumb: string
md5?: string
token?: string
key?: string
encIdx?: string
}
export interface SnsMedia {
url: string
thumb: string
md5?: string
token?: string
key?: string
encIdx?: string
livePhoto?: SnsLivePhoto
}
export interface SnsPost { export interface SnsPost {
id: string id: string
username: string username: string
@@ -10,11 +29,25 @@ export interface SnsPost {
createTime: number createTime: number
contentDesc: string contentDesc: string
type?: number type?: number
media: { url: string; thumb: string }[] 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 }[]
rawXml?: string // 原始 XML 数据
} }
const fixSnsUrl = (url: string, token?: string) => {
if (!url) return url;
// 1. 统一使用 https
// 2. 将 /150 (缩略图) 强制改为 /0 (原图)
let fixedUrl = url.replace('http://', 'https://').replace(/\/150($|\?)/, '/0$1');
if (!token || fixedUrl.includes('token=')) return fixedUrl;
const connector = fixedUrl.includes('?') ? '&' : '?';
return `${fixedUrl}${connector}token=${token}&idx=1`;
};
class SnsService { class SnsService {
private contactCache: ContactCacheService private contactCache: ContactCacheService
@@ -35,14 +68,50 @@ class SnsService {
}) })
if (result.success && result.timeline) { if (result.success && result.timeline) {
const enrichedTimeline = result.timeline.map((post: any) => { const enrichedTimeline = result.timeline.map((post: any, index: number) => {
const contact = this.contactCache.get(post.username) const contact = this.contactCache.get(post.username)
// 修复媒体 URL,如果是 http 则尝试用 https (虽然 qpic 可能不支持强制 https但通常支持) // 修复媒体 URL
const fixedMedia = post.media.map((m: any) => ({ const fixedMedia = post.media.map((m: any, mIdx: number) => {
url: m.url.replace('http://', 'https://'), const base = {
thumb: m.thumb.replace('http://', 'https://') url: fixSnsUrl(m.url, m.token),
})) thumb: fixSnsUrl(m.thumb, m.token),
md5: m.md5,
token: m.token,
key: m.key,
encIdx: m.encIdx || m.enc_idx, // 兼容不同命名
livePhoto: m.livePhoto ? {
...m.livePhoto,
url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token),
thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token),
token: m.livePhoto.token,
key: m.livePhoto.key
} : undefined
}
// [MOCK] 模拟数据:如果后端没返回 key (说明 DLL 未更新),注入一些 Mock 数据以便前端开发
if (!base.key) {
base.key = 'mock_key_for_dev'
if (!base.token) {
base.token = 'mock_token_for_dev'
base.url = fixSnsUrl(base.url, base.token)
base.thumb = fixSnsUrl(base.thumb, base.token)
}
base.encIdx = '1'
// 强制给第一个帖子的第一张图加 LivePhoto 模拟
if (index === 0 && mIdx === 0 && !base.livePhoto) {
base.livePhoto = {
url: fixSnsUrl('https://tm.sh/d4cb0.mp4', 'mock_live_token'),
thumb: base.thumb,
token: 'mock_live_token',
key: 'mock_live_key'
}
}
}
return base
})
return { return {
...post, ...post,
@@ -59,6 +128,128 @@ class SnsService {
console.log('[SnsService] Returning result:', result) console.log('[SnsService] Returning result:', result)
return result return result
} }
async debugResource(url: string): Promise<{ success: boolean; status?: number; headers?: any; error?: string }> {
return new Promise((resolve) => {
try {
const { app, net } = require('electron')
// Remove mocking 'require' if it causes issues, but here we need 'net' or 'https'
// implementing with 'https' for reliability if 'net' is main-process only special
const https = require('https')
const urlObj = new URL(url)
const options = {
hostname: urlObj.hostname,
path: urlObj.pathname + urlObj.search,
method: 'GET',
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351",
"Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "zh-CN,zh;q=0.9",
"Referer": "https://servicewechat.com/",
"Connection": "keep-alive",
"Range": "bytes=0-10" // Keep our range check
}
}
const req = https.request(options, (res: any) => {
resolve({
success: true,
status: res.statusCode,
headers: {
'x-enc': res.headers['x-enc'],
'content-length': res.headers['content-length'],
'content-type': res.headers['content-type']
}
})
req.destroy() // We only need headers
})
req.on('error', (e: any) => {
resolve({ success: false, error: e.message })
})
req.end()
} catch (e: any) {
resolve({ success: false, error: e.message })
}
})
}
private imageCache = new Map<string, string>()
async proxyImage(url: string): Promise<{ success: boolean; dataUrl?: string; error?: string }> {
// Check cache
if (this.imageCache.has(url)) {
return { success: true, dataUrl: this.imageCache.get(url) }
}
return new Promise((resolve) => {
try {
const https = require('https')
const zlib = require('zlib')
const urlObj = new URL(url)
const options = {
hostname: urlObj.hostname,
path: urlObj.pathname + urlObj.search,
method: 'GET',
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351",
"Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "zh-CN,zh;q=0.9",
"Referer": "https://servicewechat.com/",
"Connection": "keep-alive"
}
}
const req = https.request(options, (res: any) => {
if (res.statusCode !== 200) {
resolve({ success: false, error: `HTTP ${res.statusCode}` })
return
}
const chunks: Buffer[] = []
let stream = res
// Handle gzip compression
const encoding = res.headers['content-encoding']
if (encoding === 'gzip') {
stream = res.pipe(zlib.createGunzip())
} else if (encoding === 'deflate') {
stream = res.pipe(zlib.createInflate())
} else if (encoding === 'br') {
stream = res.pipe(zlib.createBrotliDecompress())
}
stream.on('data', (chunk: Buffer) => chunks.push(chunk))
stream.on('end', () => {
const buffer = Buffer.concat(chunks)
const contentType = res.headers['content-type'] || 'image/jpeg'
const base64 = buffer.toString('base64')
const dataUrl = `data:${contentType};base64,${base64}`
// Cache
this.imageCache.set(url, dataUrl)
resolve({ success: true, dataUrl })
})
stream.on('error', (e: any) => {
resolve({ success: false, error: e.message })
})
})
req.on('error', (e: any) => {
resolve({ success: false, error: e.message })
})
req.end()
} catch (e: any) {
resolve({ success: false, error: e.message })
}
})
}
} }
export const snsService = new SnsService() export const snsService = new SnsService()

View File

@@ -246,14 +246,15 @@ export class WcdbCore {
// InitProtection (Added for security) // InitProtection (Added for security)
try { try {
this.wcdbInitProtection = this.lib.func('bool InitProtection(const char* resourcePath)') this.wcdbInitProtection = this.lib.func('int32 InitProtection(const char* resourcePath)')
const protectionOk = this.wcdbInitProtection(dllDir) const protectionCode = this.wcdbInitProtection(dllDir)
if (!protectionOk) { if (protectionCode !== 0) {
console.error('Core security check failed') console.error('Core security check failed:', protectionCode)
lastDllInitError = `初始化失败,错误码: ${protectionCode}`
return false return false
} }
} catch (e) { } catch (e) {
console.warn('InitProtection symbol not found:', e) console.warn('InitProtection symbol not found or failed:', e)
} }
// 定义类型 // 定义类型

Binary file not shown.

View File

@@ -24,10 +24,25 @@ import * as configService from './services/config'
import { Download, X, Shield } from 'lucide-react' import { Download, X, Shield } from 'lucide-react'
import './App.scss' import './App.scss'
import UpdateDialog from './components/UpdateDialog'
import UpdateProgressCapsule from './components/UpdateProgressCapsule'
function App() { function App() {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const { setDbConnected } = useAppStore() const {
setDbConnected,
updateInfo,
setUpdateInfo,
isDownloading,
setIsDownloading,
downloadProgress,
setDownloadProgress,
showUpdateDialog,
setShowUpdateDialog,
setUpdateError
} = useAppStore()
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore() const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
const isAgreementWindow = location.pathname === '/agreement-window' const isAgreementWindow = location.pathname === '/agreement-window'
const isOnboardingWindow = location.pathname === '/onboarding-window' const isOnboardingWindow = location.pathname === '/onboarding-window'
@@ -39,11 +54,6 @@ function App() {
const [agreementChecked, setAgreementChecked] = useState(false) const [agreementChecked, setAgreementChecked] = useState(false)
const [agreementLoading, setAgreementLoading] = useState(true) const [agreementLoading, setAgreementLoading] = useState(true)
// 更新提示状态
const [updateInfo, setUpdateInfo] = useState<{ version: string; releaseNotes: string } | null>(null)
const [isDownloading, setIsDownloading] = useState(false)
const [downloadProgress, setDownloadProgress] = useState(0)
useEffect(() => { useEffect(() => {
const root = document.documentElement const root = document.documentElement
const body = document.body const body = document.body
@@ -148,8 +158,12 @@ function App() {
// 监听启动时的更新通知 // 监听启动时的更新通知
useEffect(() => { useEffect(() => {
const removeUpdateListener = window.electronAPI.app.onUpdateAvailable?.((info) => { const removeUpdateListener = window.electronAPI.app.onUpdateAvailable?.((info: any) => {
setUpdateInfo(info) // 发现新版本时自动打开更新弹窗
if (info) {
setUpdateInfo({ ...info, hasUpdate: true })
setShowUpdateDialog(true)
}
}) })
const removeProgressListener = window.electronAPI.app.onDownloadProgress?.((progress) => { const removeProgressListener = window.electronAPI.app.onDownloadProgress?.((progress) => {
setDownloadProgress(progress) setDownloadProgress(progress)
@@ -158,16 +172,20 @@ function App() {
removeUpdateListener?.() removeUpdateListener?.()
removeProgressListener?.() removeProgressListener?.()
} }
}, []) }, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog])
const handleUpdateNow = async () => { const handleUpdateNow = async () => {
setShowUpdateDialog(false)
setIsDownloading(true) setIsDownloading(true)
setDownloadProgress(0) setDownloadProgress({ percent: 0 })
try { try {
await window.electronAPI.app.downloadAndInstall() await window.electronAPI.app.downloadAndInstall()
} catch (e) { } catch (e: any) {
console.error('更新失败:', e) console.error('更新失败:', e)
setIsDownloading(false) setIsDownloading(false)
// Extract clean error message if possible
const errorMsg = e.message || String(e)
setUpdateError(errorMsg.includes('暂时禁用') ? '自动更新已暂时禁用' : errorMsg)
} }
} }
@@ -250,6 +268,9 @@ function App() {
<div className="app-container"> <div className="app-container">
<TitleBar /> <TitleBar />
{/* 全局悬浮进度胶囊 (处理:新版本提示、下载进度、错误提示) */}
<UpdateProgressCapsule />
{/* 用户协议弹窗 */} {/* 用户协议弹窗 */}
{showAgreement && !agreementLoading && ( {showAgreement && !agreementLoading && (
<div className="agreement-overlay"> <div className="agreement-overlay">
@@ -301,31 +322,15 @@ function App() {
</div> </div>
)} )}
{/* 更新提示 */} {/* 更新提示对话框 */}
{updateInfo && ( <UpdateDialog
<div className="update-banner"> open={showUpdateDialog}
<span className="update-text"> updateInfo={updateInfo}
<strong>v{updateInfo.version}</strong> onClose={() => setShowUpdateDialog(false)}
</span> onUpdate={handleUpdateNow}
{isDownloading ? ( isDownloading={isDownloading}
<div className="update-progress"> progress={downloadProgress}
<div className="progress-bar"> />
<div className="progress-fill" style={{ width: `${downloadProgress}%` }} />
</div>
<span>{downloadProgress.toFixed(0)}%</span>
</div>
) : (
<>
<button className="update-btn" onClick={handleUpdateNow}>
<Download size={14} />
</button>
<button className="dismiss-btn" onClick={dismissUpdate}>
<X size={14} />
</button>
</>
)}
</div>
)}
<div className="main-layout"> <div className="main-layout">
<Sidebar /> <Sidebar />

View File

@@ -0,0 +1,29 @@
import React from 'react';
interface LivePhotoIconProps {
size?: number | string;
className?: string;
style?: React.CSSProperties;
}
export const LivePhotoIcon: React.FC<LivePhotoIconProps> = ({ size = 24, className = '', style = {} }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
className={className}
style={style}
>
<g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd" strokeLinecap="round" strokeLinejoin="round">
<g stroke="currentColor" strokeWidth="2">
<circle fill="currentColor" stroke="none" cx="12" cy="12" r="2.5"></circle>
<circle cx="12" cy="12" r="5.5"></circle>
<circle cx="12" cy="12" r="9" strokeDasharray="1 3.7"></circle>
</g>
</g>
</svg>
);
};

View File

@@ -0,0 +1,251 @@
.update-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
animation: fadeIn 0.3s ease-out;
.update-dialog {
width: 680px;
background: #f5f5f5;
border-radius: 24px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
overflow: hidden;
position: relative;
animation: slideUp 0.3s ease-out;
display: flex;
flex-direction: column;
/* Top Section (White/Gradient) */
.dialog-header {
background: #ffffff;
padding: 40px 20px 30px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
position: relative;
/* Subtle radial gradient effect in top left as seen in image */
&::before {
content: '';
position: absolute;
top: -50px;
left: -50px;
width: 200px;
height: 200px;
background: radial-gradient(circle, rgba(255, 235, 220, 0.4) 0%, rgba(255, 255, 255, 0) 70%);
opacity: 0.8;
pointer-events: none;
}
.version-tag {
background: #f0eee9;
color: #8c7b6e;
padding: 4px 16px;
border-radius: 12px;
font-size: 13px;
font-weight: 600;
margin-bottom: 24px;
letter-spacing: 0.5px;
}
h2 {
font-size: 32px;
font-weight: 800;
color: #333333;
margin: 0 0 12px;
letter-spacing: -0.5px;
}
.subtitle {
font-size: 15px;
color: #999999;
font-weight: 400;
}
}
/* Content Section (Light Gray) */
.dialog-content {
background: #f2f2f2;
padding: 24px 40px 40px;
flex: 1;
display: flex;
flex-direction: column;
.update-notes-container {
display: flex;
align-items: flex-start;
padding: 20px 0;
margin-bottom: 30px;
.icon-box {
background: #fbfbfb; // Beige-ish white
width: 48px;
height: 48px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20px;
flex-shrink: 0;
color: #8c7b6e;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.03);
svg {
opacity: 0.8;
}
}
.text-box {
flex: 1;
h3 {
font-size: 18px;
font-weight: 700;
color: #333333;
margin: 0 0 8px;
}
p {
font-size: 14px;
color: #666666;
line-height: 1.6;
margin: 0;
}
ul {
margin: 8px 0 0 18px;
padding: 0;
li {
font-size: 14px;
color: #666666;
line-height: 1.6;
}
}
}
}
.progress-section {
margin-bottom: 30px;
.progress-info-row {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 12px;
color: #888;
font-weight: 500;
}
.progress-bar-bg {
height: 6px;
background: #e0e0e0;
border-radius: 3px;
overflow: hidden;
.progress-bar-fill {
height: 100%;
background: #000000;
border-radius: 3px;
transition: width 0.3s ease;
}
}
.status-text {
text-align: center;
margin-top: 12px;
font-size: 13px;
color: #666;
}
}
.actions {
display: flex;
justify-content: center;
.btn-update {
background: #000000;
color: #ffffff;
border: none;
padding: 16px 48px;
border-radius: 20px; // Pill shape
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
&:active {
transform: translateY(0);
}
&:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
}
}
}
.close-btn {
position: absolute;
top: 16px;
right: 16px;
background: rgba(0, 0, 0, 0.05);
border: none;
color: #999;
cursor: pointer;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
z-index: 10;
&:hover {
background: rgba(0, 0, 0, 0.1);
color: #333;
transform: rotate(90deg);
}
}
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}

View File

@@ -0,0 +1,132 @@
import React, { useEffect, useState } from 'react'
import { Quote, X } from 'lucide-react'
import './UpdateDialog.scss'
interface UpdateInfo {
version?: string
releaseNotes?: string
}
interface UpdateDialogProps {
open: boolean
updateInfo: UpdateInfo | null
onClose: () => void
onUpdate: () => void
isDownloading: boolean
progress: number | {
percent: number
bytesPerSecond?: number
transferred?: number
total?: number
remaining?: number // seconds
}
}
const UpdateDialog: React.FC<UpdateDialogProps> = ({
open,
updateInfo,
onClose,
onUpdate,
isDownloading,
progress
}) => {
if (!open || !updateInfo) return null
// Safe normalize progress
const safeProgress = typeof progress === 'number' ? { percent: progress } : (progress || { percent: 0 })
const percent = safeProgress.percent || 0
const bytesPerSecond = safeProgress.bytesPerSecond
const total = safeProgress.total
const transferred = safeProgress.transferred
const remaining = safeProgress.remaining
// Format bytes
const formatBytes = (bytes: number) => {
if (!Number.isFinite(bytes) || bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
const unitIndex = Math.max(0, Math.min(i, sizes.length - 1))
return parseFloat((bytes / Math.pow(k, unitIndex)).toFixed(1)) + ' ' + sizes[unitIndex]
}
// Format speed
const formatSpeed = (bytesPerSecond: number) => {
return `${formatBytes(bytesPerSecond)}/s`
}
// Format time
const formatTime = (seconds: number) => {
if (!Number.isFinite(seconds)) return '计算中...'
if (seconds < 60) return `${Math.ceil(seconds)}`
const minutes = Math.floor(seconds / 60)
const remainingSeconds = Math.ceil(seconds % 60)
return `${minutes}${remainingSeconds}`
}
return (
<div className="update-dialog-overlay">
<div className="update-dialog">
{!isDownloading && (
<button className="close-btn" onClick={onClose}>
<X size={20} />
</button>
)}
<div className="dialog-header">
<div className="version-tag">
{updateInfo.version}
</div>
<h2> WeFlow</h2>
<div className="subtitle"></div>
</div>
<div className="dialog-content">
<div className="update-notes-container">
<div className="icon-box">
<Quote size={20} />
</div>
<div className="text-box">
<h3></h3>
{updateInfo.releaseNotes ? (
<div dangerouslySetInnerHTML={{ __html: updateInfo.releaseNotes }} />
) : (
<p></p>
)}
</div>
</div>
{isDownloading ? (
<div className="progress-section">
<div className="progress-info-row">
<span>{bytesPerSecond ? formatSpeed(bytesPerSecond) : '下载中...'}</span>
<span>{total ? `${formatBytes(transferred || 0)} / ${formatBytes(total)}` : `${percent.toFixed(1)}%`}</span>
{remaining !== undefined && <span> {formatTime(remaining)}</span>}
</div>
<div className="progress-bar-bg">
<div
className="progress-bar-fill"
style={{ width: `${percent}%` }}
/>
</div>
{/* Fallback status text if detailed info is missing */}
{(!bytesPerSecond && !total) && (
<div className="status-text">{percent.toFixed(0)}% </div>
)}
</div>
) : (
<div className="actions">
<button className="btn-update" onClick={onUpdate}>
</button>
</div>
)}
</div>
</div>
</div>
)
}
export default UpdateDialog

View File

@@ -0,0 +1,192 @@
.update-progress-capsule {
position: fixed;
top: 38px; // Just below title bar
left: 50%;
transform: translateX(-50%);
z-index: 9998;
cursor: pointer;
animation: capsuleSlideDown 0.4s cubic-bezier(0.16, 1, 0.3, 1);
user-select: none;
&:hover {
.capsule-content {
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
transform: scale(1.02);
}
}
.capsule-content {
background: var(--bg-primary);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
padding: 8px 18px;
border-radius: 24px;
border: 1px solid var(--border-color);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.12);
display: flex;
align-items: center;
gap: 12px;
height: 40px;
position: relative;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
.icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary);
.download-icon {
animation: capsulePulse 2s infinite ease-in-out;
}
}
.info-wrapper {
display: flex;
align-items: baseline;
gap: 10px;
z-index: 1;
.percent-text {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.speed-text {
font-size: 13px;
color: var(--text-tertiary);
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.error-text {
font-size: 15px;
color: #ff4d4f;
font-weight: 600;
}
.available-text {
font-size: 15px;
color: var(--text-primary);
font-weight: 600;
}
}
.progress-bg {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: rgba(0, 0, 0, 0.05);
.progress-fill {
height: 100%;
background: var(--primary);
transition: width 0.3s ease;
}
}
.capsule-close {
background: none;
border: none;
padding: 4px;
margin-left: -4px;
margin-right: -8px;
cursor: pointer;
opacity: 0.5;
transition: all 0.2s ease;
display: flex;
align-items: center;
color: var(--text-secondary);
&:hover {
opacity: 1;
background: var(--bg-tertiary);
border-radius: 50%;
}
}
}
// State Modifiers
&.state-available {
.capsule-content {
background: var(--primary);
border-color: rgba(255, 255, 255, 0.1);
color: white;
.icon-wrapper {
color: white;
}
.info-wrapper {
.available-text {
color: white;
}
}
.capsule-close {
color: rgba(255, 255, 255, 0.8);
&:hover {
background: rgba(255, 255, 255, 0.1);
}
}
}
}
&.state-downloading {
.capsule-content {
background: var(--bg-primary);
}
}
&.state-error {
.capsule-content {
background: #fff1f0;
border-color: #ffa39e;
.icon-wrapper {
color: #ff4d4f;
}
.info-wrapper .error-text {
color: #cf1322;
}
.capsule-close {
color: #cf1322;
}
}
}
}
@keyframes capsuleSlideDown {
from {
transform: translate(-50%, -40px);
opacity: 0;
}
to {
transform: translate(-50%, 0);
opacity: 1;
}
}
@keyframes capsulePulse {
0%,
100% {
transform: translateY(0);
opacity: 1;
}
50% {
transform: translateY(2px);
opacity: 0.6;
}
}

View File

@@ -0,0 +1,118 @@
import React from 'react'
import { useAppStore } from '../stores/appStore'
import { Download, X, AlertCircle, Info } from 'lucide-react'
import './UpdateProgressCapsule.scss'
const UpdateProgressCapsule: React.FC = () => {
const {
isDownloading,
downloadProgress,
showUpdateDialog,
setShowUpdateDialog,
updateInfo,
setUpdateInfo,
updateError,
setUpdateError
} = useAppStore()
// Control visibility
// If dialog is open, we usually hide the capsule UNLESS we want it as a mini-indicator
// For now, let's hide it if the dialog is open
if (showUpdateDialog) return null
// State mapping
const hasError = !!updateError
const hasUpdate = !!updateInfo && updateInfo.hasUpdate
if (!hasError && !isDownloading && !hasUpdate) return null
// Safe normalize progress
const safeProgress = typeof downloadProgress === 'number' ? { percent: downloadProgress } : (downloadProgress || { percent: 0 })
const percent = safeProgress.percent || 0
const bytesPerSecond = safeProgress.bytesPerSecond
const formatBytes = (bytes: number) => {
if (!Number.isFinite(bytes) || bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
const unitIndex = Math.max(0, Math.min(i, sizes.length - 1))
return parseFloat((bytes / Math.pow(k, unitIndex)).toFixed(1)) + ' ' + sizes[unitIndex]
}
const formatSpeed = (bps: number) => {
return `${formatBytes(bps)}/s`
}
const handleClose = (e: React.MouseEvent) => {
e.stopPropagation()
if (hasError) {
setUpdateError(null)
} else if (hasUpdate && !isDownloading) {
setUpdateInfo(null)
}
}
// Determine appearance class and content
let capsuleClass = 'update-progress-capsule'
let content = null
if (hasError) {
capsuleClass += ' state-error'
content = (
<>
<div className="icon-wrapper">
<AlertCircle size={14} />
</div>
<div className="info-wrapper">
<span className="error-text">: {updateError}</span>
</div>
</>
)
} else if (isDownloading) {
capsuleClass += ' state-downloading'
content = (
<>
<div className="icon-wrapper">
<Download size={14} className="download-icon" />
</div>
<div className="info-wrapper">
<span className="percent-text">{percent.toFixed(0)}%</span>
{bytesPerSecond > 0 && (
<span className="speed-text">{formatSpeed(bytesPerSecond)}</span>
)}
</div>
<div className="progress-bg">
<div className="progress-fill" style={{ width: `${percent}%` }} />
</div>
</>
)
} else if (hasUpdate) {
capsuleClass += ' state-available'
content = (
<>
<div className="icon-wrapper">
<Info size={14} />
</div>
<div className="info-wrapper">
<span className="available-text"> v{updateInfo?.version}</span>
</div>
</>
)
}
return (
<div className={capsuleClass} onClick={() => setShowUpdateDialog(true)}>
<div className="capsule-content">
{content}
{!isDownloading && (
<button className="capsule-close" onClick={handleClose}>
<X size={12} />
</button>
)}
</div>
</div>
)
}
export default UpdateProgressCapsule

View File

@@ -29,7 +29,22 @@ interface WxidOption {
} }
function SettingsPage() { function SettingsPage() {
const { isDbConnected, setDbConnected, setLoading, reset } = useAppStore() const {
isDbConnected,
setDbConnected,
setLoading,
reset,
updateInfo,
setUpdateInfo,
isDownloading,
setIsDownloading,
downloadProgress,
setDownloadProgress,
showUpdateDialog,
setShowUpdateDialog,
setUpdateError
} = useAppStore()
const resetChatStore = useChatStore((state) => state.reset) const resetChatStore = useChatStore((state) => state.reset)
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore() const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
const clearAnalyticsStoreCache = useAnalyticsStore((state) => state.clearCache) const clearAnalyticsStoreCache = useAnalyticsStore((state) => state.clearCache)
@@ -69,10 +84,7 @@ function SettingsPage() {
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false) const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
const [isFetchingImageKey, setIsFetchingImageKey] = useState(false) const [isFetchingImageKey, setIsFetchingImageKey] = useState(false)
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false) const [isCheckingUpdate, setIsCheckingUpdate] = useState(false)
const [isDownloading, setIsDownloading] = useState(false)
const [downloadProgress, setDownloadProgress] = useState(0)
const [appVersion, setAppVersion] = useState('') const [appVersion, setAppVersion] = useState('')
const [updateInfo, setUpdateInfo] = useState<{ hasUpdate: boolean; version?: string; releaseNotes?: string } | null>(null)
const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null) const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null)
const [showDecryptKey, setShowDecryptKey] = useState(false) const [showDecryptKey, setShowDecryptKey] = useState(false)
const [dbKeyStatus, setDbKeyStatus] = useState('') const [dbKeyStatus, setDbKeyStatus] = useState('')
@@ -209,7 +221,7 @@ function SettingsPage() {
// 监听下载进度 // 监听下载进度
useEffect(() => { useEffect(() => {
const removeListener = window.electronAPI.app.onDownloadProgress?.((progress: number) => { const removeListener = window.electronAPI.app.onDownloadProgress?.((progress: any) => {
setDownloadProgress(progress) setDownloadProgress(progress)
}) })
return () => removeListener?.() return () => removeListener?.()
@@ -229,12 +241,14 @@ function SettingsPage() {
}, [whisperModelDir]) }, [whisperModelDir])
const handleCheckUpdate = async () => { const handleCheckUpdate = async () => {
if (isCheckingUpdate) return
setIsCheckingUpdate(true) setIsCheckingUpdate(true)
setUpdateInfo(null) setUpdateInfo(null)
try { try {
const result = await window.electronAPI.app.checkForUpdates() const result = await window.electronAPI.app.checkForUpdates()
if (result.hasUpdate) { if (result.hasUpdate) {
setUpdateInfo(result) setUpdateInfo(result)
setShowUpdateDialog(true)
showMessage(`发现新版:${result.version}`, true) showMessage(`发现新版:${result.version}`, true)
} else { } else {
showMessage('当前已是最新版', true) showMessage('当前已是最新版', true)
@@ -247,8 +261,10 @@ function SettingsPage() {
} }
const handleUpdateNow = async () => { const handleUpdateNow = async () => {
setShowUpdateDialog(false)
setIsDownloading(true) setIsDownloading(true)
setDownloadProgress(0) setDownloadProgress({ percent: 0 })
try { try {
showMessage('正在下载更新...', true) showMessage('正在下载更新...', true)
await window.electronAPI.app.downloadAndInstall() await window.electronAPI.app.downloadAndInstall()
@@ -258,6 +274,8 @@ function SettingsPage() {
} }
} }
const showMessage = (text: string, success: boolean) => { const showMessage = (text: string, success: boolean) => {
setMessage({ text, success }) setMessage({ text, success })
setTimeout(() => setMessage(null), 3000) setTimeout(() => setMessage(null), 3000)
@@ -1204,23 +1222,26 @@ function SettingsPage() {
<> <>
<p className="update-hint"> v{updateInfo.version} </p> <p className="update-hint"> v{updateInfo.version} </p>
{isDownloading ? ( {isDownloading ? (
<div className="download-progress"> <div className="update-progress">
<div className="progress-bar"> <div className="progress-bar">
<div className="progress-fill" style={{ width: `${downloadProgress}%` }} /> <div className="progress-inner" style={{ width: `${(downloadProgress?.percent || 0)}%` }} />
</div> </div>
<span>{downloadProgress.toFixed(0)}%</span> <span>{(downloadProgress?.percent || 0).toFixed(0)}%</span>
</div> </div>
) : ( ) : (
<button className="btn btn-primary" onClick={handleUpdateNow}> <button className="btn btn-primary" onClick={() => setShowUpdateDialog(true)}>
<Download size={16} /> <Download size={16} />
</button> </button>
)} )}
</> </>
) : ( ) : (
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
<button className="btn btn-secondary" onClick={handleCheckUpdate} disabled={isCheckingUpdate}> <button className="btn btn-secondary" onClick={handleCheckUpdate} disabled={isCheckingUpdate}>
<RefreshCw size={16} className={isCheckingUpdate ? 'spin' : ''} /> <RefreshCw size={16} className={isCheckingUpdate ? 'spin' : ''} />
{isCheckingUpdate ? '检查中...' : '检查更新'} {isCheckingUpdate ? '检查中...' : '检查更新'}
</button> </button>
</div>
)} )}
</div> </div>
</div> </div>
@@ -1299,6 +1320,7 @@ function SettingsPage() {
{activeTab === 'cache' && renderCacheTab()} {activeTab === 'cache' && renderCacheTab()}
{activeTab === 'about' && renderAboutTab()} {activeTab === 'about' && renderAboutTab()}
</div> </div>
</div> </div>
) )
} }

View File

@@ -10,70 +10,47 @@
} }
.sns-sidebar { .sns-sidebar {
width: 300px; width: 320px;
background: var(--bg-secondary); background: var(--bg-secondary);
border-right: 1px solid var(--border-color); border-left: 1px solid var(--border-color);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
flex-shrink: 0; flex-shrink: 0;
z-index: 10; z-index: 10;
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.05);
&.closed { &.closed {
width: 0; width: 0;
opacity: 0; opacity: 0;
transform: translateX(-100%); transform: translateX(100%);
pointer-events: none; pointer-events: none;
border-left: none;
} }
.sidebar-header { .sidebar-header {
padding: 18px 20px; padding: 0 24px;
height: 64px;
box-sizing: border-box;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; /* justify-content: space-between; -- No longer needed as it's just h3 */
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
.title-wrapper {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-primary);
.title-icon {
color: var(--accent-color);
}
h3 { h3 {
margin: 0; margin: 0;
font-size: 15px; font-size: 18px;
font-weight: 600; font-weight: 700;
letter-spacing: 0.5px; color: var(--text-primary);
} letter-spacing: 0;
}
.toggle-btn {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
cursor: pointer;
padding: 5px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: all 0.2s;
&:hover {
background: var(--hover-bg);
color: var(--accent-color);
border-color: var(--accent-color);
}
} }
} }
.filter-content { .filter-content {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: hidden;
/* Changed from auto to hidden to allow inner scrolling of contact list */
padding: 16px; padding: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -86,6 +63,7 @@
padding: 14px; padding: 14px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02);
transition: transform 0.2s, box-shadow 0.2s; transition: transform 0.2s, box-shadow 0.2s;
flex-shrink: 0;
&:hover { &:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.04); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.04);
@@ -172,7 +150,7 @@
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; // 改为 0 以支持 flex 压缩 min-height: 200px;
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
} }
@@ -181,7 +159,7 @@
.filter-section { .filter-section {
margin-bottom: 20px; margin-bottom: 0px;
label { label {
display: flex; display: flex;
@@ -258,12 +236,16 @@
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden;
.section-header { .section-header {
padding: 16px 16px 1px 16px; padding: 16px 16px 1px 16px;
margin-bottom: 12px;
/* Increased spacing */
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
flex-shrink: 0;
.header-actions { .header-actions {
display: flex; display: flex;
@@ -306,6 +288,7 @@
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
flex-shrink: 0;
.search-icon { .search-icon {
position: absolute; position: absolute;
@@ -354,6 +337,7 @@
overflow-y: auto; overflow-y: auto;
padding: 4px 8px; padding: 4px 8px;
margin: 0 4px 8px 4px; margin: 0 4px 8px 4px;
min-height: 0;
.contact-item { .contact-item {
display: flex; display: flex;
@@ -524,6 +508,12 @@
} }
} }
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.icon-btn { .icon-btn {
background: none; background: none;
border: none; border: none;
@@ -553,6 +543,7 @@
} }
} }
.sns-content-wrapper { .sns-content-wrapper {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -739,6 +730,61 @@
cursor: zoom-in; cursor: zoom-in;
} }
.live-badge {
position: absolute;
top: 8px;
left: 8px;
right: 8px;
left: auto;
background: rgba(255, 255, 255, 0.9);
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
color: white;
padding: 4px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 2;
transition: opacity 0.2s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.download-btn-overlay {
position: absolute;
bottom: 6px;
right: 6px;
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transform: translateY(10px);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 2;
&:hover {
background: rgba(0, 0, 0, 0.7);
transform: scale(1.1);
border-color: rgba(255, 255, 255, 0.8);
}
}
&:hover {
.download-btn-overlay {
opacity: 1;
transform: translateY(0);
}
}
.media-error-placeholder { .media-error-placeholder {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -938,3 +984,196 @@
opacity: 1; opacity: 1;
} }
} }
// Debug Dialog Styles
.debug-btn {
margin-left: auto;
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 6px;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&:hover {
background: var(--hover-bg);
color: var(--accent-color);
border-color: var(--accent-color);
}
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
backdrop-filter: blur(4px);
}
.debug-dialog {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
width: 90%;
max-width: 800px;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
.debug-dialog-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.close-btn {
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
border-radius: 4px;
transition: all 0.2s;
&:hover {
background: var(--hover-bg);
color: var(--accent-color);
}
}
}
.debug-dialog-body {
flex: 1;
overflow-y: auto;
padding: 20px;
.debug-section {
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border-color);
&:last-child {
border-bottom: none;
}
h4 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: var(--accent-color);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.debug-item {
display: flex;
gap: 12px;
padding: 8px 0;
align-items: flex-start;
.debug-key {
font-weight: 500;
color: var(--text-secondary);
min-width: 140px;
font-size: 13px;
font-family: 'Consolas', 'Microsoft YaHei', 'SimHei', monospace;
}
.debug-value {
flex: 1;
color: var(--text-primary);
font-size: 13px;
word-break: break-all;
font-family: 'Consolas', 'Microsoft YaHei', 'SimHei', monospace;
user-select: text;
cursor: text;
padding: 2px 0;
}
}
.media-debug-item {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
.media-debug-header {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-color);
}
.live-photo-debug {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed var(--border-color);
.live-photo-label {
font-weight: 500;
color: var(--accent-color);
margin-bottom: 8px;
font-size: 13px;
}
}
}
.json-code {
background: var(--bg-tertiary);
color: var(--text-primary);
padding: 16px;
border-radius: 8px;
border: 1px solid var(--border-color);
overflow-x: auto;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
line-height: 1.5;
user-select: all;
max-height: 400px;
overflow-y: auto;
}
.copy-json-btn {
margin-top: 12px;
padding: 8px 16px;
background: var(--accent-color);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
&:hover {
background: var(--accent-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(var(--accent-color-rgb), 0.3);
}
}
}
}
}

View File

@@ -1,8 +1,9 @@
import { useEffect, useState, useRef, useCallback, useMemo } from 'react' import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
import { RefreshCw, Heart, Search, Calendar, User, X, Filter, Play, ImageIcon } from 'lucide-react' import { RefreshCw, Heart, Search, Calendar, User, X, Filter, Play, ImageIcon, Zap, Download, ChevronRight } from 'lucide-react'
import { Avatar } from '../components/Avatar' import { Avatar } from '../components/Avatar'
import { ImagePreview } from '../components/ImagePreview' import { ImagePreview } from '../components/ImagePreview'
import JumpToDateDialog from '../components/JumpToDateDialog' import JumpToDateDialog from '../components/JumpToDateDialog'
import { LivePhotoIcon } from '../components/LivePhotoIcon'
import './SnsPage.scss' import './SnsPage.scss'
interface SnsPost { interface SnsPost {
@@ -13,29 +14,64 @@ interface SnsPost {
createTime: number createTime: number
contentDesc: string contentDesc: string
type?: number type?: number
media: { url: string; thumb: string }[] media: {
url: string
thumb: string
md5?: string
token?: string
key?: string
encIdx?: string
livePhoto?: {
url: string
thumb: string
token?: string
key?: string
encIdx?: string
}
}[]
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 }[]
rawXml?: string // 原始 XML 数据
} }
const MediaItem = ({ url, thumb, onPreview }: { url: string, thumb: string, onPreview: () => void }) => { const MediaItem = ({ media, onPreview }: { media: any, onPreview: () => void }) => {
const [error, setError] = useState(false); const [error, setError] = useState(false);
const { url, thumb, livePhoto } = media;
const isLive = !!livePhoto;
const targetUrl = thumb || url;
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation();
let downloadUrl = url;
let downloadKey = media.key || '';
if (isLive && media.livePhoto) {
downloadUrl = media.livePhoto.url;
downloadKey = media.livePhoto.key || '';
}
// TODO: 调用后端下载服务
// window.electronAPI.sns.download(downloadUrl, downloadKey);
};
return ( return (
<div className={`media-item ${error ? 'error' : ''}`}> <div className={`media-item ${error ? 'error' : ''}`} onClick={onPreview}>
{!error ? (
<img <img
src={thumb || url} src={targetUrl}
alt="" alt=""
referrerPolicy="no-referrer"
loading="lazy" loading="lazy"
onClick={onPreview}
onError={() => setError(true)} onError={() => setError(true)}
/> />
) : ( {isLive && (
<div className="media-error-placeholder" onClick={onPreview}> <div className="live-badge">
<ImageIcon size={24} style={{ opacity: 0.3 }} /> <LivePhotoIcon size={16} className="live-icon" />
</div> </div>
)} )}
<button className="download-btn-overlay" onClick={handleDownload} title="下载原图">
<Download size={14} />
</button>
</div> </div>
); );
}; };
@@ -65,6 +101,7 @@ export default function SnsPage() {
const [showJumpDialog, setShowJumpDialog] = useState(false) const [showJumpDialog, setShowJumpDialog] = useState(false)
const [jumpTargetDate, setJumpTargetDate] = useState<Date | undefined>(undefined) const [jumpTargetDate, setJumpTargetDate] = useState<Date | undefined>(undefined)
const [previewImage, setPreviewImage] = useState<string | null>(null) const [previewImage, setPreviewImage] = useState<string | null>(null)
const [debugPost, setDebugPost] = useState<SnsPost | null>(null)
const postsContainerRef = useRef<HTMLDivElement>(null) const postsContainerRef = useRef<HTMLDivElement>(null)
@@ -264,7 +301,7 @@ export default function SnsPage() {
setHasNewer(false) setHasNewer(false)
setSelectedUsernames([]) setSelectedUsernames([])
setSearchKeyword('') setSearchKeyword('')
setJumpTargetDate(null) setJumpTargetDate(undefined)
loadContacts() loadContacts()
loadPosts({ reset: true }) loadPosts({ reset: true })
} }
@@ -347,16 +384,157 @@ export default function SnsPage() {
return ( return (
<div className="sns-page"> <div className="sns-page">
<div className="sns-container"> <div className="sns-container">
{/* 侧边栏:过滤与搜索 */} <main className="sns-main">
<div className="sns-header">
<div className="header-left">
<h2></h2>
</div>
<div className="header-right">
<button
className={`icon-btn sidebar-trigger ${isSidebarOpen ? 'active' : ''}`}
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
title={isSidebarOpen ? "收起筛选" : "打开筛选"}
>
<Filter size={18} />
</button>
<button
onClick={() => {
if (jumpTargetDate) setJumpTargetDate(undefined);
loadPosts({ reset: true });
}}
disabled={loading || loadingNewer}
className="icon-btn refresh-btn"
title="刷新"
>
<RefreshCw size={18} className={(loading || loadingNewer) ? 'spinning' : ''} />
</button>
</div>
</div>
<div className="sns-content-wrapper">
<div className="sns-content custom-scrollbar" onScroll={handleScroll} onWheel={handleWheel} ref={postsContainerRef}>
<div className="posts-list">
{loadingNewer && (
<div className="status-indicator loading-newer">
<RefreshCw size={16} className="spinning" />
<span>...</span>
</div>
)}
{!loadingNewer && hasNewer && (
<div className="status-indicator newer-hint" onClick={() => loadPosts({ direction: 'newer' })}>
</div>
)}
{posts.map((post, index) => {
return (
<div key={post.id} className="sns-post-row">
<div className="sns-post-wrapper">
<div className="sns-post">
<div className="post-header">
<Avatar
src={post.avatarUrl}
name={post.nickname}
size={44}
shape="rounded"
/>
<div className="post-info">
<div className="nickname">{post.nickname}</div>
<div className="time">{formatTime(post.createTime)}</div>
</div>
<button
className="debug-btn"
onClick={(e) => {
e.stopPropagation();
setDebugPost(post);
}}
title="查看原始数据"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="16 18 22 12 16 6"></polyline>
<polyline points="8 6 2 12 8 18"></polyline>
</svg>
</button>
</div>
<div className="post-body">
{post.contentDesc && <div className="post-text">{post.contentDesc}</div>}
{post.type === 15 ? (
<div className="post-video-placeholder">
<Play size={20} />
<span></span>
</div>
) : post.media.length > 0 && (
<div className={`post-media-grid media-count-${Math.min(post.media.length, 9)}`}>
{post.media.map((m, idx) => (
<MediaItem key={idx} media={m} onPreview={() => setPreviewImage(m.url)} />
))}
</div>
)}
</div>
{(post.likes.length > 0 || post.comments.length > 0) && (
<div className="post-footer">
{post.likes.length > 0 && (
<div className="likes-section">
<Heart size={14} className="icon" />
<span className="likes-list">
{post.likes.join('、')}
</span>
</div>
)}
{post.comments.length > 0 && (
<div className="comments-section">
{post.comments.map((c, idx) => (
<div key={idx} className="comment-item">
<span className="comment-user">{c.nickname}</span>
{c.refNickname && (
<>
<span className="reply-text"></span>
<span className="comment-user">{c.refNickname}</span>
</>
)}
<span className="comment-separator">: </span>
<span className="comment-content">{c.content}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
</div>
)
})}
</div>
{loading && <div className="status-indicator loading-more">
<RefreshCw size={16} className="spinning" />
<span>...</span>
</div>}
{!hasMore && posts.length > 0 && <div className="status-indicator no-more"></div>}
{!loading && posts.length === 0 && (
<div className="no-results">
<div className="no-results-icon"><Search size={48} /></div>
<p></p>
{(selectedUsernames.length > 0 || searchKeyword) && (
<button onClick={clearFilters} className="reset-inline">
</button>
)}
</div>
)}
</div>
</div>
</main>
{/* 侧边栏:过滤与搜索 (moved to right) */}
<aside className={`sns-sidebar ${isSidebarOpen ? 'open' : 'closed'}`}> <aside className={`sns-sidebar ${isSidebarOpen ? 'open' : 'closed'}`}>
<div className="sidebar-header"> <div className="sidebar-header">
<div className="title-wrapper">
<Filter size={18} className="title-icon" />
<h3></h3> <h3></h3>
</div>
<button className="toggle-btn" onClick={() => setIsSidebarOpen(false)}>
<X size={18} />
</button>
</div> </div>
<div className="filter-content custom-scrollbar"> <div className="filter-content custom-scrollbar">
@@ -460,136 +638,6 @@ export default function SnsPage() {
</button> </button>
</div> </div>
</aside> </aside>
<main className="sns-main">
<div className="sns-header">
<div className="header-left">
{!isSidebarOpen && (
<button className="icon-btn sidebar-trigger" onClick={() => setIsSidebarOpen(true)}>
<Filter size={20} />
</button>
)}
<h2></h2>
</div>
<div className="header-right">
<button
onClick={() => {
if (jumpTargetDate) setJumpTargetDate(undefined);
loadPosts({ reset: true });
}}
disabled={loading || loadingNewer}
className="icon-btn refresh-btn"
>
<RefreshCw size={18} className={(loading || loadingNewer) ? 'spinning' : ''} />
</button>
</div>
</div>
<div className="sns-content-wrapper">
<div className="sns-content custom-scrollbar" onScroll={handleScroll} onWheel={handleWheel} ref={postsContainerRef}>
<div className="posts-list">
{loadingNewer && (
<div className="status-indicator loading-newer">
<RefreshCw size={16} className="spinning" />
<span>...</span>
</div>
)}
{!loadingNewer && hasNewer && (
<div className="status-indicator newer-hint" onClick={() => loadPosts({ direction: 'newer' })}>
</div>
)}
{posts.map((post, index) => {
return (
<div key={post.id} className="sns-post-row">
<div className="sns-post-wrapper">
<div className="sns-post">
<div className="post-header">
<Avatar
src={post.avatarUrl}
name={post.nickname}
size={44}
shape="rounded"
/>
<div className="post-info">
<div className="nickname">{post.nickname}</div>
<div className="time">{formatTime(post.createTime)}</div>
</div>
</div>
<div className="post-body">
{post.contentDesc && <div className="post-text">{post.contentDesc}</div>}
{post.type === 15 ? (
<div className="post-video-placeholder">
<Play size={20} />
<span></span>
</div>
) : post.media.length > 0 && (
<div className={`post-media-grid media-count-${Math.min(post.media.length, 9)}`}>
{post.media.map((m, idx) => (
<MediaItem key={idx} url={m.url} thumb={m.thumb} onPreview={() => setPreviewImage(m.url)} />
))}
</div>
)}
</div>
{(post.likes.length > 0 || post.comments.length > 0) && (
<div className="post-footer">
{post.likes.length > 0 && (
<div className="likes-section">
<Heart size={14} className="icon" />
<span className="likes-list">
{post.likes.join('、')}
</span>
</div>
)}
{post.comments.length > 0 && (
<div className="comments-section">
{post.comments.map((c, idx) => (
<div key={idx} className="comment-item">
<span className="comment-user">{c.nickname}</span>
{c.refNickname && (
<>
<span className="reply-text"></span>
<span className="comment-user">{c.refNickname}</span>
</>
)}
<span className="comment-separator">: </span>
<span className="comment-content">{c.content}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
</div>
)
})}
</div>
{loading && <div className="status-indicator loading-more">
<RefreshCw size={16} className="spinning" />
<span>...</span>
</div>}
{!hasMore && posts.length > 0 && <div className="status-indicator no-more"></div>}
{!loading && posts.length === 0 && (
<div className="no-results">
<div className="no-results-icon"><Search size={48} /></div>
<p></p>
{(selectedUsernames.length > 0 || searchKeyword) && (
<button onClick={clearFilters} className="reset-inline">
</button>
)}
</div>
)}
</div>
</div>
</main>
</div> </div>
{previewImage && ( {previewImage && (
<ImagePreview src={previewImage} onClose={() => setPreviewImage(null)} /> <ImagePreview src={previewImage} onClose={() => setPreviewImage(null)} />
@@ -605,6 +653,154 @@ export default function SnsPage() {
}} }}
currentDate={jumpTargetDate || new Date()} currentDate={jumpTargetDate || new Date()}
/> />
{/* Debug Info Dialog */}
{debugPost && (
<div className="modal-overlay" onClick={() => setDebugPost(null)}>
<div className="debug-dialog" onClick={(e) => e.stopPropagation()}>
<div className="debug-dialog-header">
<h3> - {debugPost.nickname}</h3>
<button className="close-btn" onClick={() => setDebugPost(null)}>
<X size={20} />
</button>
</div>
<div className="debug-dialog-body">
<div className="debug-section">
<h4> </h4>
<div className="debug-item">
<span className="debug-key">ID:</span>
<span className="debug-value">{debugPost.id}</span>
</div>
<div className="debug-item">
<span className="debug-key">:</span>
<span className="debug-value">{debugPost.username}</span>
</div>
<div className="debug-item">
<span className="debug-key">:</span>
<span className="debug-value">{debugPost.nickname}</span>
</div>
<div className="debug-item">
<span className="debug-key">:</span>
<span className="debug-value">{new Date(debugPost.createTime * 1000).toLocaleString()}</span>
</div>
<div className="debug-item">
<span className="debug-key">:</span>
<span className="debug-value">{debugPost.type}</span>
</div>
</div>
<div className="debug-section">
<h4> ({debugPost.media.length} )</h4>
{debugPost.media.map((media, idx) => (
<div key={idx} className="media-debug-item">
<div className="media-debug-header"> {idx + 1}</div>
<div className="debug-item">
<span className="debug-key">URL:</span>
<span className="debug-value">{media.url}</span>
</div>
<div className="debug-item">
<span className="debug-key">:</span>
<span className="debug-value">{media.thumb}</span>
</div>
{media.md5 && (
<div className="debug-item">
<span className="debug-key">MD5:</span>
<span className="debug-value">{media.md5}</span>
</div>
)}
{media.token && (
<div className="debug-item">
<span className="debug-key">Token:</span>
<span className="debug-value">{media.token}</span>
</div>
)}
{media.key && (
<div className="debug-item">
<span className="debug-key">Key ():</span>
<span className="debug-value">{media.key}</span>
</div>
)}
{media.encIdx && (
<div className="debug-item">
<span className="debug-key">Enc Index:</span>
<span className="debug-value">{media.encIdx}</span>
</div>
)}
{media.livePhoto && (
<div className="live-photo-debug">
<div className="live-photo-label"> Live Photo :</div>
<div className="debug-item">
<span className="debug-key"> URL:</span>
<span className="debug-value">{media.livePhoto.url}</span>
</div>
<div className="debug-item">
<span className="debug-key">:</span>
<span className="debug-value">{media.livePhoto.thumb}</span>
</div>
{media.livePhoto.token && (
<div className="debug-item">
<span className="debug-key"> Token:</span>
<span className="debug-value">{media.livePhoto.token}</span>
</div>
)}
{media.livePhoto.key && (
<div className="debug-item">
<span className="debug-key"> Key:</span>
<span className="debug-value">{media.livePhoto.key}</span>
</div>
)}
</div>
)}
</div>
))}
</div>
{/* 原始 XML */}
{debugPost.rawXml && (
<div className="debug-section">
<h4> XML </h4>
<pre className="json-code">{(() => {
// XML 缩进格式化
let formatted = '';
let indent = 0;
const tab = ' ';
const parts = debugPost.rawXml.split(/(<[^>]+>)/g).filter(p => p.trim());
for (const part of parts) {
if (!part.startsWith('<')) {
if (part.trim()) formatted += part;
continue;
}
if (part.startsWith('</')) {
indent = Math.max(0, indent - 1);
formatted += '\n' + tab.repeat(indent) + part;
} else if (part.endsWith('/>')) {
formatted += '\n' + tab.repeat(indent) + part;
} else {
formatted += '\n' + tab.repeat(indent) + part;
indent++;
}
}
return formatted.trim();
})()}</pre>
<button
className="copy-json-btn"
onClick={() => {
navigator.clipboard.writeText(debugPost.rawXml || '');
alert('已复制 XML 到剪贴板');
}}
>
XML
</button>
</div>
)}
</div>
</div>
</div>
)}
</div> </div>
) )
} }

View File

@@ -10,10 +10,29 @@ export interface AppState {
isLoading: boolean isLoading: boolean
loadingText: string loadingText: string
// 更新状态
updateInfo: {
hasUpdate: boolean
version?: string
releaseNotes?: string
} | null
isDownloading: boolean
downloadProgress: any
showUpdateDialog: boolean
updateError: string | null
// 操作 // 操作
setDbConnected: (connected: boolean, path?: string) => void setDbConnected: (connected: boolean, path?: string) => void
setMyWxid: (wxid: string) => void setMyWxid: (wxid: string) => void
setLoading: (loading: boolean, text?: string) => void setLoading: (loading: boolean, text?: string) => void
// 更新操作
setUpdateInfo: (info: any) => void
setIsDownloading: (isDownloading: boolean) => void
setDownloadProgress: (progress: any) => void
setShowUpdateDialog: (show: boolean) => void
setUpdateError: (error: string | null) => void
reset: () => void reset: () => void
} }
@@ -24,6 +43,13 @@ export const useAppStore = create<AppState>((set) => ({
isLoading: false, isLoading: false,
loadingText: '', loadingText: '',
// 更新状态初始化
updateInfo: null,
isDownloading: false,
downloadProgress: { percent: 0 },
showUpdateDialog: false,
updateError: null,
setDbConnected: (connected, path) => set({ setDbConnected: (connected, path) => set({
isDbConnected: connected, isDbConnected: connected,
dbPath: path ?? null dbPath: path ?? null
@@ -36,11 +62,22 @@ export const useAppStore = create<AppState>((set) => ({
loadingText: text ?? '' loadingText: text ?? ''
}), }),
setUpdateInfo: (info) => set({ updateInfo: info, updateError: null }),
setIsDownloading: (isDownloading) => set({ isDownloading: isDownloading }),
setDownloadProgress: (progress) => set({ downloadProgress: progress }),
setShowUpdateDialog: (show) => set({ showUpdateDialog: show }),
setUpdateError: (error) => set({ updateError: error }),
reset: () => set({ reset: () => set({
isDbConnected: false, isDbConnected: false,
dbPath: null, dbPath: null,
myWxid: null, myWxid: null,
isLoading: false, isLoading: false,
loadingText: '' loadingText: '',
updateInfo: null,
isDownloading: false,
downloadProgress: { percent: 0 },
showUpdateDialog: false,
updateError: null
}) })
})) }))

View File

@@ -333,12 +333,30 @@ export interface ElectronAPI {
createTime: number createTime: number
contentDesc: string contentDesc: string
type?: number type?: number
media: Array<{ url: string; thumb: string }> media: Array<{
url: string
thumb: string
md5?: string
token?: string
key?: string
encIdx?: string
livePhoto?: {
url: string
thumb: string
md5?: string
token?: string
key?: string
encIdx?: string
}
}>
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 }>
rawXml?: string
}> }>
error?: string error?: string
}> }>
debugResource: (url: string) => Promise<{ success: boolean; status?: number; headers?: any; error?: string }>
proxyImage: (url: string) => Promise<{ success: boolean; dataUrl?: string; error?: string }>
} }
} }