mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 23:35:49 +00:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afbd52a91e | ||
|
|
1c6e14acb4 | ||
|
|
6968936c8f | ||
|
|
a571278145 | ||
|
|
e4e25394e2 | ||
|
|
fe47d7b9e3 | ||
|
|
4bb5bc6e32 | ||
|
|
49d951e96a | ||
|
|
9585a02959 | ||
|
|
a51fa5e4a2 | ||
|
|
bc0671440c | ||
|
|
1a07c3970f | ||
|
|
83c07b27f9 | ||
|
|
fbcf7d2fc3 | ||
|
|
b547ac1aed | ||
|
|
411f8a8d61 | ||
|
|
b3741a5cf4 | ||
|
|
b1cf524612 | ||
|
|
364c920fff | ||
|
|
e89ccee5f4 | ||
|
|
6a86e69cd4 | ||
|
|
ab2c086e93 | ||
|
|
b9c65e634c | ||
|
|
b7852a8c07 | ||
|
|
4b9d94eb62 | ||
|
|
70481fd468 | ||
|
|
52c67f4d23 | ||
|
|
d3618f3065 | ||
|
|
29472beee8 | ||
|
|
acaac507b1 | ||
|
|
f25c23b2b3 | ||
|
|
5ab0466a87 | ||
|
|
d49c44f3be | ||
|
|
4577b4e955 | ||
|
|
dafde2eaba | ||
|
|
6e8ae3a12b | ||
|
|
a4be7f9005 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -61,4 +61,6 @@ wcdb/
|
||||
chatlab-format.md
|
||||
*.bak
|
||||
AGENTS.md
|
||||
.claude/
|
||||
.claude/
|
||||
.agents/
|
||||
resources/wx_send
|
||||
@@ -19,6 +19,7 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
||||
</a>
|
||||
<a href="https://github.com/hicccc77/WeFlow/issues">
|
||||
<img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square" alt="Issues">
|
||||
<img src="https://gh-down-badges.linkof.link/hicccc77/WeFlow/" alt="Downloads" />
|
||||
</a>
|
||||
<a href="https://t.me/weflow_cc">
|
||||
<img src="https://img.shields.io/badge/Telegram%20频道-0088cc?style=flat-square&logo=telegram&logoColor=0088cc&labelColor=white" alt="Telegram">
|
||||
|
||||
219
electron/main.ts
219
electron/main.ts
@@ -21,7 +21,7 @@ import { videoService } from './services/videoService'
|
||||
import { snsService, isVideoUrl } from './services/snsService'
|
||||
import { contactExportService } from './services/contactExportService'
|
||||
import { windowsHelloService } from './services/windowsHelloService'
|
||||
import { llamaService } from './services/llamaService'
|
||||
|
||||
import { registerNotificationHandlers, showNotification } from './windows/notificationWindow'
|
||||
import { httpService } from './services/httpService'
|
||||
|
||||
@@ -87,6 +87,11 @@ const keyService = new KeyService()
|
||||
let mainWindowReady = false
|
||||
let shouldShowMain = true
|
||||
|
||||
// 更新下载状态管理(Issue #294 修复)
|
||||
let isDownloadInProgress = false
|
||||
let downloadProgressHandler: ((progress: any) => void) | null = null
|
||||
let downloadedHandler: (() => void) | null = null
|
||||
|
||||
function createWindow(options: { autoShow?: boolean } = {}) {
|
||||
// 获取图标路径 - 打包后在 resources 目录
|
||||
const { autoShow = true } = options
|
||||
@@ -173,6 +178,20 @@ function createWindow(options: { autoShow?: boolean } = {}) {
|
||||
}
|
||||
)
|
||||
|
||||
// 忽略微信 CDN 域名的证书错误(部分节点证书配置不正确)
|
||||
win.webContents.on('certificate-error', (event, url, _error, _cert, callback) => {
|
||||
const trusted = ['.qq.com', '.qpic.cn', '.weixin.qq.com', '.wechat.com']
|
||||
try {
|
||||
const host = new URL(url).hostname
|
||||
if (trusted.some(d => host.endsWith(d))) {
|
||||
event.preventDefault()
|
||||
callback(true)
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
callback(false)
|
||||
})
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
@@ -383,7 +402,7 @@ function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHe
|
||||
/**
|
||||
* 创建独立的图片查看窗口
|
||||
*/
|
||||
function createImageViewerWindow(imagePath: string) {
|
||||
function createImageViewerWindow(imagePath: string, liveVideoPath?: string) {
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||
const iconPath = isDev
|
||||
? join(__dirname, '../public/icon.ico')
|
||||
@@ -416,7 +435,8 @@ function createImageViewerWindow(imagePath: string) {
|
||||
win.show()
|
||||
})
|
||||
|
||||
const imageParam = `imagePath=${encodeURIComponent(imagePath)}`
|
||||
let imageParam = `imagePath=${encodeURIComponent(imagePath)}`
|
||||
if (liveVideoPath) imageParam += `&liveVideoPath=${encodeURIComponent(liveVideoPath)}`
|
||||
|
||||
if (process.env.VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/image-viewer-window?${imageParam}`)
|
||||
@@ -603,22 +623,61 @@ function registerIpcHandlers() {
|
||||
if (!AUTO_UPDATE_ENABLED) {
|
||||
throw new Error('自动更新已暂时禁用')
|
||||
}
|
||||
|
||||
// 防止重复下载(Issue #294 修复)
|
||||
if (isDownloadInProgress) {
|
||||
throw new Error('更新正在下载中,请稍候')
|
||||
}
|
||||
|
||||
isDownloadInProgress = true
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
|
||||
// 监听下载进度
|
||||
autoUpdater.on('download-progress', (progress) => {
|
||||
win?.webContents.send('app:downloadProgress', progress)
|
||||
})
|
||||
// 清理旧的监听器(Issue #294 修复:防止监听器泄漏)
|
||||
if (downloadProgressHandler) {
|
||||
autoUpdater.removeListener('download-progress', downloadProgressHandler)
|
||||
downloadProgressHandler = null
|
||||
}
|
||||
if (downloadedHandler) {
|
||||
autoUpdater.removeListener('update-downloaded', downloadedHandler)
|
||||
downloadedHandler = null
|
||||
}
|
||||
|
||||
// 下载完成后自动安装
|
||||
autoUpdater.on('update-downloaded', () => {
|
||||
// 创建新的监听器并保存引用
|
||||
downloadProgressHandler = (progress) => {
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send('app:downloadProgress', progress)
|
||||
}
|
||||
}
|
||||
|
||||
downloadedHandler = () => {
|
||||
console.log('[Update] 更新下载完成,准备安装')
|
||||
if (downloadProgressHandler) {
|
||||
autoUpdater.removeListener('download-progress', downloadProgressHandler)
|
||||
downloadProgressHandler = null
|
||||
}
|
||||
downloadedHandler = null
|
||||
isDownloadInProgress = false
|
||||
autoUpdater.quitAndInstall(false, true)
|
||||
})
|
||||
}
|
||||
|
||||
autoUpdater.on('download-progress', downloadProgressHandler)
|
||||
autoUpdater.once('update-downloaded', downloadedHandler)
|
||||
|
||||
try {
|
||||
console.log('[Update] 开始下载更新...')
|
||||
await autoUpdater.downloadUpdate()
|
||||
} catch (error) {
|
||||
console.error('下载更新失败:', error)
|
||||
console.error('[Update] 下载更新失败:', error)
|
||||
// 失败时清理状态和监听器
|
||||
isDownloadInProgress = false
|
||||
if (downloadProgressHandler) {
|
||||
autoUpdater.removeListener('download-progress', downloadProgressHandler)
|
||||
downloadProgressHandler = null
|
||||
}
|
||||
if (downloadedHandler) {
|
||||
autoUpdater.removeListener('update-downloaded', downloadedHandler)
|
||||
downloadedHandler = null
|
||||
}
|
||||
throw error
|
||||
}
|
||||
})
|
||||
@@ -811,63 +870,6 @@ function registerIpcHandlers() {
|
||||
return await chatService.getContact(username)
|
||||
})
|
||||
|
||||
// Llama AI
|
||||
ipcMain.handle('llama:init', async () => {
|
||||
return await llamaService.init()
|
||||
})
|
||||
|
||||
ipcMain.handle('llama:loadModel', async (_, modelPath: string) => {
|
||||
return llamaService.loadModel(modelPath)
|
||||
})
|
||||
|
||||
ipcMain.handle('llama:createSession', async (_, systemPrompt?: string) => {
|
||||
return llamaService.createSession(systemPrompt)
|
||||
})
|
||||
|
||||
ipcMain.handle('llama:chat', async (event, message: string, options?: { thinking?: boolean }) => {
|
||||
// We use a callback to stream back to the renderer
|
||||
const webContents = event.sender
|
||||
try {
|
||||
if (!webContents) return { success: false, error: 'No sender' }
|
||||
|
||||
const response = await llamaService.chat(message, options, (token) => {
|
||||
if (!webContents.isDestroyed()) {
|
||||
webContents.send('llama:token', token)
|
||||
}
|
||||
})
|
||||
return { success: true, response }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('llama:downloadModel', async (event, url: string, savePath: string) => {
|
||||
const webContents = event.sender
|
||||
try {
|
||||
await llamaService.downloadModel(url, savePath, (payload) => {
|
||||
if (!webContents.isDestroyed()) {
|
||||
webContents.send('llama:downloadProgress', payload)
|
||||
}
|
||||
})
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('llama:getModelsPath', async () => {
|
||||
return llamaService.getModelsPath()
|
||||
})
|
||||
|
||||
ipcMain.handle('llama:checkFileExists', async (_, filePath: string) => {
|
||||
const { existsSync } = await import('fs')
|
||||
return existsSync(filePath)
|
||||
})
|
||||
|
||||
ipcMain.handle('llama:getModelStatus', async (_, modelPath: string) => {
|
||||
return llamaService.getModelStatus(modelPath)
|
||||
})
|
||||
|
||||
|
||||
ipcMain.handle('chat:getContactAvatar', async (_, username: string) => {
|
||||
return await chatService.getContactAvatar(username)
|
||||
@@ -912,6 +914,9 @@ function registerIpcHandlers() {
|
||||
ipcMain.handle('chat:getAllVoiceMessages', async (_, sessionId: string) => {
|
||||
return chatService.getAllVoiceMessages(sessionId)
|
||||
})
|
||||
ipcMain.handle('chat:getAllImageMessages', async (_, sessionId: string) => {
|
||||
return chatService.getAllImageMessages(sessionId)
|
||||
})
|
||||
ipcMain.handle('chat:getMessageDates', async (_, sessionId: string) => {
|
||||
return chatService.getMessageDates(sessionId)
|
||||
})
|
||||
@@ -937,6 +942,10 @@ function registerIpcHandlers() {
|
||||
return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
|
||||
})
|
||||
|
||||
ipcMain.handle('sns:getSnsUsernames', async () => {
|
||||
return snsService.getSnsUsernames()
|
||||
})
|
||||
|
||||
ipcMain.handle('sns:debugResource', async (_, url: string) => {
|
||||
return snsService.debugResource(url)
|
||||
})
|
||||
@@ -1025,7 +1034,65 @@ function registerIpcHandlers() {
|
||||
? mainWindow
|
||||
: (BrowserWindow.fromWebContents(event.sender) || undefined)
|
||||
|
||||
return windowsHelloService.verify(message, targetWin)
|
||||
const result = await windowsHelloService.verify(message, targetWin)
|
||||
|
||||
// Hello 验证成功后,自动用 authHelloSecret 中的密码解锁密钥
|
||||
if (result && configService) {
|
||||
const secret = configService.getHelloSecret()
|
||||
if (secret && configService.isLockMode()) {
|
||||
configService.unlock(secret)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// 验证应用锁状态(检测 lock: 前缀,防篡改)
|
||||
ipcMain.handle('auth:verifyEnabled', async () => {
|
||||
return configService?.verifyAuthEnabled() ?? false
|
||||
})
|
||||
|
||||
// 密码解锁(验证 + 解密密钥到内存)
|
||||
ipcMain.handle('auth:unlock', async (_event, password: string) => {
|
||||
if (!configService) return { success: false, error: '配置服务未初始化' }
|
||||
return configService.unlock(password)
|
||||
})
|
||||
|
||||
// 开启应用锁
|
||||
ipcMain.handle('auth:enableLock', async (_event, password: string) => {
|
||||
if (!configService) return { success: false, error: '配置服务未初始化' }
|
||||
return configService.enableLock(password)
|
||||
})
|
||||
|
||||
// 关闭应用锁
|
||||
ipcMain.handle('auth:disableLock', async (_event, password: string) => {
|
||||
if (!configService) return { success: false, error: '配置服务未初始化' }
|
||||
return configService.disableLock(password)
|
||||
})
|
||||
|
||||
// 修改密码
|
||||
ipcMain.handle('auth:changePassword', async (_event, oldPassword: string, newPassword: string) => {
|
||||
if (!configService) return { success: false, error: '配置服务未初始化' }
|
||||
return configService.changePassword(oldPassword, newPassword)
|
||||
})
|
||||
|
||||
// 设置 Hello Secret
|
||||
ipcMain.handle('auth:setHelloSecret', async (_event, password: string) => {
|
||||
if (!configService) return { success: false }
|
||||
configService.setHelloSecret(password)
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
// 清除 Hello Secret
|
||||
ipcMain.handle('auth:clearHelloSecret', async () => {
|
||||
if (!configService) return { success: false }
|
||||
configService.clearHelloSecret()
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
// 检查是否处于 lock: 模式
|
||||
ipcMain.handle('auth:isLockMode', async () => {
|
||||
return configService?.isLockMode() ?? false
|
||||
})
|
||||
|
||||
// 导出相关
|
||||
@@ -1158,8 +1225,18 @@ function registerIpcHandlers() {
|
||||
})
|
||||
|
||||
// 打开图片查看窗口
|
||||
ipcMain.handle('window:openImageViewerWindow', (_, imagePath: string) => {
|
||||
createImageViewerWindow(imagePath)
|
||||
ipcMain.handle('window:openImageViewerWindow', async (_, imagePath: string, liveVideoPath?: string) => {
|
||||
// 如果是 dataUrl,写入临时文件
|
||||
if (imagePath.startsWith('data:')) {
|
||||
const commaIdx = imagePath.indexOf(',')
|
||||
const meta = imagePath.slice(5, commaIdx) // e.g. "image/jpeg;base64"
|
||||
const ext = meta.split('/')[1]?.split(';')[0] || 'jpg'
|
||||
const tmpPath = join(app.getPath('temp'), `weflow_preview_${Date.now()}.${ext}`)
|
||||
await writeFile(tmpPath, Buffer.from(imagePath.slice(commaIdx + 1), 'base64'))
|
||||
createImageViewerWindow(`file://${tmpPath.replace(/\\/g, '/')}`, liveVideoPath)
|
||||
} else {
|
||||
createImageViewerWindow(imagePath, liveVideoPath)
|
||||
}
|
||||
})
|
||||
|
||||
// 完成引导,关闭引导窗口并显示主窗口
|
||||
|
||||
@@ -24,7 +24,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
|
||||
// 认证
|
||||
auth: {
|
||||
hello: (message?: string) => ipcRenderer.invoke('auth:hello', message)
|
||||
hello: (message?: string) => ipcRenderer.invoke('auth:hello', message),
|
||||
verifyEnabled: () => ipcRenderer.invoke('auth:verifyEnabled'),
|
||||
unlock: (password: string) => ipcRenderer.invoke('auth:unlock', password),
|
||||
enableLock: (password: string) => ipcRenderer.invoke('auth:enableLock', password),
|
||||
disableLock: (password: string) => ipcRenderer.invoke('auth:disableLock', password),
|
||||
changePassword: (oldPassword: string, newPassword: string) => ipcRenderer.invoke('auth:changePassword', oldPassword, newPassword),
|
||||
setHelloSecret: (password: string) => ipcRenderer.invoke('auth:setHelloSecret', password),
|
||||
clearHelloSecret: () => ipcRenderer.invoke('auth:clearHelloSecret'),
|
||||
isLockMode: () => ipcRenderer.invoke('auth:isLockMode')
|
||||
},
|
||||
|
||||
|
||||
@@ -78,8 +86,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
|
||||
resizeToFitVideo: (videoWidth: number, videoHeight: number) =>
|
||||
ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight),
|
||||
openImageViewerWindow: (imagePath: string) =>
|
||||
ipcRenderer.invoke('window:openImageViewerWindow', imagePath),
|
||||
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) =>
|
||||
ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
|
||||
openChatHistoryWindow: (sessionId: string, messageId: number) =>
|
||||
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId)
|
||||
},
|
||||
@@ -146,6 +154,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
|
||||
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),
|
||||
getAllVoiceMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId),
|
||||
getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId),
|
||||
getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId),
|
||||
resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId),
|
||||
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
|
||||
@@ -276,6 +285,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
sns: {
|
||||
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
|
||||
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
|
||||
getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'),
|
||||
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
|
||||
proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
|
||||
downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload),
|
||||
@@ -287,27 +297,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
selectExportDir: () => ipcRenderer.invoke('sns:selectExportDir')
|
||||
},
|
||||
|
||||
// Llama AI
|
||||
llama: {
|
||||
loadModel: (modelPath: string) => ipcRenderer.invoke('llama:loadModel', modelPath),
|
||||
createSession: (systemPrompt?: string) => ipcRenderer.invoke('llama:createSession', systemPrompt),
|
||||
chat: (message: string, options?: any) => ipcRenderer.invoke('llama:chat', message, options),
|
||||
downloadModel: (url: string, savePath: string) => ipcRenderer.invoke('llama:downloadModel', url, savePath),
|
||||
getModelsPath: () => ipcRenderer.invoke('llama:getModelsPath'),
|
||||
checkFileExists: (filePath: string) => ipcRenderer.invoke('llama:checkFileExists', filePath),
|
||||
getModelStatus: (modelPath: string) => ipcRenderer.invoke('llama:getModelStatus', modelPath),
|
||||
onToken: (callback: (token: string) => void) => {
|
||||
const listener = (_: any, token: string) => callback(token)
|
||||
ipcRenderer.on('llama:token', listener)
|
||||
return () => ipcRenderer.removeListener('llama:token', listener)
|
||||
},
|
||||
onDownloadProgress: (callback: (payload: { downloaded: number; total: number; speed: number }) => void) => {
|
||||
const listener = (_: any, payload: { downloaded: number; total: number; speed: number }) => callback(payload)
|
||||
ipcRenderer.on('llama:downloadProgress', listener)
|
||||
return () => ipcRenderer.removeListener('llama:downloadProgress', listener)
|
||||
}
|
||||
},
|
||||
|
||||
// HTTP API 服务
|
||||
http: {
|
||||
start: (port?: number) => ipcRenderer.invoke('http:start', port),
|
||||
|
||||
@@ -79,14 +79,14 @@ class AnalyticsService {
|
||||
const chunkSize = 200
|
||||
for (let i = 0; i < usernames.length; i += chunkSize) {
|
||||
const chunk = usernames.slice(i, i + chunkSize)
|
||||
const inList = chunk.map((u) => `'${this.escapeSqlValue(u)}'`).join(',')
|
||||
if (!inList) continue
|
||||
// 使用参数化查询防止SQL注入
|
||||
const placeholders = chunk.map(() => '?').join(',')
|
||||
const sql = `
|
||||
SELECT username, alias
|
||||
FROM contact
|
||||
WHERE username IN (${inList})
|
||||
WHERE username IN (${placeholders})
|
||||
`
|
||||
const result = await wcdbService.execQuery('contact', null, sql)
|
||||
const result = await wcdbService.execQuery('contact', null, sql, chunk)
|
||||
if (!result.success || !result.rows) continue
|
||||
for (const row of result.rows as Record<string, any>[]) {
|
||||
const username = row.username || ''
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { join, dirname, basename, extname } from 'path'
|
||||
import { join, dirname, basename, extname } from 'path'
|
||||
import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, watch } from 'fs'
|
||||
import * as path from 'path'
|
||||
import * as fs from 'fs'
|
||||
@@ -13,6 +13,7 @@ import { wcdbService } from './wcdbService'
|
||||
import { MessageCacheService } from './messageCacheService'
|
||||
import { ContactCacheService, ContactCacheEntry } from './contactCacheService'
|
||||
import { voiceTranscribeService } from './voiceTranscribeService'
|
||||
import { LRUCache } from '../utils/LRUCache.js'
|
||||
|
||||
type HardlinkState = {
|
||||
db: Database.Database
|
||||
@@ -72,9 +73,36 @@ export interface Message {
|
||||
fileSize?: number // 文件大小
|
||||
fileExt?: string // 文件扩展名
|
||||
xmlType?: string // XML 中的 type 字段
|
||||
appMsgKind?: string // 归一化 appmsg 类型
|
||||
appMsgDesc?: string
|
||||
appMsgAppName?: string
|
||||
appMsgSourceName?: string
|
||||
appMsgSourceUsername?: string
|
||||
appMsgThumbUrl?: string
|
||||
appMsgMusicUrl?: string
|
||||
appMsgDataUrl?: string
|
||||
appMsgLocationLabel?: string
|
||||
finderNickname?: string
|
||||
finderUsername?: string
|
||||
finderCoverUrl?: string
|
||||
finderAvatar?: string
|
||||
finderDuration?: number
|
||||
// 位置消息
|
||||
locationLat?: number
|
||||
locationLng?: number
|
||||
locationPoiname?: string
|
||||
locationLabel?: string
|
||||
// 音乐消息
|
||||
musicAlbumUrl?: string
|
||||
musicUrl?: string
|
||||
// 礼物消息
|
||||
giftImageUrl?: string
|
||||
giftWish?: string
|
||||
giftPrice?: string
|
||||
// 名片消息
|
||||
cardUsername?: string // 名片的微信ID
|
||||
cardNickname?: string // 名片的昵称
|
||||
cardAvatarUrl?: string // 名片头像 URL
|
||||
// 转账消息
|
||||
transferPayerUsername?: string // 转账付款人
|
||||
transferReceiverUsername?: string // 转账收款人
|
||||
@@ -103,7 +131,7 @@ export interface ContactInfo {
|
||||
remark?: string
|
||||
nickname?: string
|
||||
avatarUrl?: string
|
||||
type: 'friend' | 'group' | 'official' | 'other'
|
||||
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||
}
|
||||
|
||||
// 表情包缓存
|
||||
@@ -114,6 +142,7 @@ class ChatService {
|
||||
private configService: ConfigService
|
||||
private connected = false
|
||||
private messageCursors: Map<string, { cursor: number; fetched: number; batchSize: number; startTime?: number; endTime?: number; ascending?: boolean; bufferedMessages?: any[] }> = new Map()
|
||||
private messageCursorMutex: boolean = false
|
||||
private readonly messageBatchDefault = 50
|
||||
private avatarCache: Map<string, ContactCacheEntry>
|
||||
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
||||
@@ -121,8 +150,8 @@ class ChatService {
|
||||
private hardlinkCache = new Map<string, HardlinkState>()
|
||||
private readonly contactCacheService: ContactCacheService
|
||||
private readonly messageCacheService: MessageCacheService
|
||||
private voiceWavCache = new Map<string, Buffer>()
|
||||
private voiceTranscriptCache = new Map<string, string>()
|
||||
private voiceWavCache: LRUCache<string, Buffer>
|
||||
private voiceTranscriptCache: LRUCache<string, string>
|
||||
private voiceTranscriptPending = new Map<string, Promise<{ success: boolean; transcript?: string; error?: string }>>()
|
||||
private transcriptCacheLoaded = false
|
||||
private transcriptCacheDirty = false
|
||||
@@ -149,6 +178,9 @@ class ChatService {
|
||||
const persisted = this.contactCacheService.getAllEntries()
|
||||
this.avatarCache = new Map(Object.entries(persisted))
|
||||
this.messageCacheService = new MessageCacheService(this.configService.getCacheBasePath())
|
||||
// 初始化LRU缓存,限制大小防止内存泄漏
|
||||
this.voiceWavCache = new LRUCache(this.voiceWavCacheMaxEntries)
|
||||
this.voiceTranscriptCache = new LRUCache(1000) // 最多缓存1000条转写记录
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -603,7 +635,7 @@ class ChatService {
|
||||
// 使用execQuery直接查询加密的contact.db
|
||||
// kind='contact', path=null表示使用已打开的contact.db
|
||||
const contactQuery = `
|
||||
SELECT username, remark, nick_name, alias, local_type
|
||||
SELECT username, remark, nick_name, alias, local_type, flag, quan_pin
|
||||
FROM contact
|
||||
`
|
||||
|
||||
@@ -651,45 +683,23 @@ class ChatService {
|
||||
for (const row of rows) {
|
||||
const username = row.username || ''
|
||||
|
||||
// 过滤系统账号和特殊账号 - 完全复制cipher的逻辑
|
||||
if (!username) continue
|
||||
if (username === 'filehelper' || username === 'fmessage' || username === 'floatbottle' ||
|
||||
username === 'medianote' || username === 'newsapp' || username.startsWith('fake_') ||
|
||||
username === 'weixin' || username === 'qmessage' || username === 'qqmail' ||
|
||||
username === 'tmessage' || username.startsWith('wxid_') === false &&
|
||||
username.includes('@') === false && username.startsWith('gh_') === false &&
|
||||
/^[a-zA-Z0-9_-]+$/.test(username) === false) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 判断类型 - 正确规则:wxid开头且有alias的是好友
|
||||
let type: 'friend' | 'group' | 'official' | 'other' = 'other'
|
||||
const localType = row.local_type || 0
|
||||
const excludeNames = ['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage']
|
||||
let type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' = 'other'
|
||||
const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0)
|
||||
const flag = Number(row.flag ?? 0)
|
||||
const quanPin = this.getRowField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) || ''
|
||||
|
||||
if (username.includes('@chatroom')) {
|
||||
type = 'group'
|
||||
} else if (username.startsWith('gh_')) {
|
||||
type = 'official'
|
||||
} else if (localType === 3 || localType === 4) {
|
||||
type = 'official'
|
||||
} else if (username.startsWith('wxid_') && row.alias) {
|
||||
// wxid开头且有alias的是好友
|
||||
} else if (/^(?!.*(gh_|@chatroom)).*$/.test(username) && localType === 1 && !excludeNames.includes(username)) {
|
||||
type = 'friend'
|
||||
} else if (localType === 1) {
|
||||
// local_type=1 也是好友
|
||||
type = 'friend'
|
||||
} else if (localType === 2) {
|
||||
// local_type=2 是群成员但非好友,跳过
|
||||
continue
|
||||
} else if (localType === 0) {
|
||||
// local_type=0 可能是好友或其他,检查是否有备注或昵称
|
||||
if (row.remark || row.nick_name) {
|
||||
type = 'friend'
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
} else if (/^(?!.*(gh_|@chatroom)).*$/.test(username) && localType === 0 && quanPin) {
|
||||
type = 'former_friend'
|
||||
} else {
|
||||
// 其他未知类型,跳过
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -750,6 +760,13 @@ class ChatService {
|
||||
}
|
||||
|
||||
const batchSize = Math.max(1, limit || this.messageBatchDefault)
|
||||
|
||||
// 使用互斥锁保护游标状态访问
|
||||
while (this.messageCursorMutex) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1))
|
||||
}
|
||||
this.messageCursorMutex = true
|
||||
|
||||
let state = this.messageCursors.get(sessionId)
|
||||
|
||||
// 只在以下情况重新创建游标:
|
||||
@@ -787,6 +804,7 @@ class ChatService {
|
||||
|
||||
state = { cursor: cursorResult.cursor, fetched: 0, batchSize, startTime, endTime, ascending }
|
||||
this.messageCursors.set(sessionId, state)
|
||||
this.messageCursorMutex = false
|
||||
|
||||
// 如果需要跳过消息(offset > 0),逐批获取但不返回
|
||||
// 注意:仅在 offset === 0 时重建游标最安全;
|
||||
@@ -847,6 +865,10 @@ class ChatService {
|
||||
let rows: any[] = state.bufferedMessages || []
|
||||
state.bufferedMessages = undefined // Clear buffer after use
|
||||
|
||||
// Track actual hasMore status from C++ layer
|
||||
// If we have buffered messages, we need to check if there's more data
|
||||
let actualHasMore = rows.length > 0 // If buffer exists, assume there might be more
|
||||
|
||||
// If buffer is not enough to fill a batch, try to fetch more
|
||||
// Or if buffer is empty, fetch a batch
|
||||
if (rows.length < batchSize) {
|
||||
@@ -854,6 +876,7 @@ class ChatService {
|
||||
if (nextBatch.success && nextBatch.rows) {
|
||||
rows = rows.concat(nextBatch.rows)
|
||||
state.fetched += nextBatch.rows.length
|
||||
actualHasMore = nextBatch.hasMore === true
|
||||
} else if (!nextBatch.success) {
|
||||
console.error('[ChatService] 获取消息批次失败:', nextBatch.error)
|
||||
// If we have some buffered rows, we can still return them?
|
||||
@@ -861,6 +884,7 @@ class ChatService {
|
||||
if (rows.length === 0) {
|
||||
return { success: false, error: nextBatch.error || '获取消息失败' }
|
||||
}
|
||||
actualHasMore = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -871,13 +895,43 @@ class ChatService {
|
||||
// Next time offset will catch up or mismatch trigger reset.
|
||||
}
|
||||
|
||||
const hasMore = rows.length > 0 // Simplified hasMore check for now, can be improved
|
||||
// Use actual hasMore from C++ layer, not simplified row count check
|
||||
const hasMore = actualHasMore
|
||||
|
||||
const normalized = this.normalizeMessageOrder(this.mapRowsToMessages(rows))
|
||||
|
||||
// 🔒 安全验证:过滤掉不属于当前 sessionId 的消息(防止 C++ 层或缓存错误)
|
||||
const filtered = normalized.filter(msg => {
|
||||
// 检查消息的 senderUsername 或 rawContent 中的 talker
|
||||
// 群聊消息:senderUsername 是群成员,需要检查 _db_path 或上下文
|
||||
// 单聊消息:senderUsername 应该是 sessionId 或自己
|
||||
const isGroupChat = sessionId.includes('@chatroom')
|
||||
|
||||
if (isGroupChat) {
|
||||
// 群聊消息暂不验证(因为 senderUsername 是群成员,不是 sessionId)
|
||||
return true
|
||||
} else {
|
||||
// 单聊消息:senderUsername 应该是 sessionId(对方)或为空/null(自己)
|
||||
if (!msg.senderUsername || msg.senderUsername === sessionId) {
|
||||
return true
|
||||
}
|
||||
// 如果 isSend 为 1,说明是自己发的,允许通过
|
||||
if (msg.isSend === 1) {
|
||||
return true
|
||||
}
|
||||
// 其他情况:可能是错误的消息
|
||||
console.warn(`[ChatService] 检测到异常消息: sessionId=${sessionId}, senderUsername=${msg.senderUsername}, localId=${msg.localId}`)
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
if (filtered.length < normalized.length) {
|
||||
console.warn(`[ChatService] 过滤了 ${normalized.length - filtered.length} 条异常消息`)
|
||||
}
|
||||
|
||||
// 并发检查并修复缺失 CDN URL 的表情包
|
||||
const fixPromises: Promise<void>[] = []
|
||||
for (const msg of normalized) {
|
||||
for (const msg of filtered) {
|
||||
if (msg.localType === 47 && !msg.emojiCdnUrl && msg.emojiMd5) {
|
||||
fixPromises.push(this.fallbackEmoticon(msg))
|
||||
}
|
||||
@@ -888,9 +942,12 @@ class ChatService {
|
||||
}
|
||||
|
||||
state.fetched += rows.length
|
||||
this.messageCacheService.set(sessionId, normalized)
|
||||
return { success: true, messages: normalized, hasMore }
|
||||
this.messageCursorMutex = false
|
||||
|
||||
this.messageCacheService.set(sessionId, filtered)
|
||||
return { success: true, messages: filtered, hasMore }
|
||||
} catch (e) {
|
||||
this.messageCursorMutex = false
|
||||
console.error('ChatService: 获取消息失败:', e)
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
@@ -1194,9 +1251,33 @@ class ChatService {
|
||||
let fileSize: number | undefined
|
||||
let fileExt: string | undefined
|
||||
let xmlType: string | undefined
|
||||
let appMsgKind: string | undefined
|
||||
let appMsgDesc: string | undefined
|
||||
let appMsgAppName: string | undefined
|
||||
let appMsgSourceName: string | undefined
|
||||
let appMsgSourceUsername: string | undefined
|
||||
let appMsgThumbUrl: string | undefined
|
||||
let appMsgMusicUrl: string | undefined
|
||||
let appMsgDataUrl: string | undefined
|
||||
let appMsgLocationLabel: string | undefined
|
||||
let finderNickname: string | undefined
|
||||
let finderUsername: string | undefined
|
||||
let finderCoverUrl: string | undefined
|
||||
let finderAvatar: string | undefined
|
||||
let finderDuration: number | undefined
|
||||
let locationLat: number | undefined
|
||||
let locationLng: number | undefined
|
||||
let locationPoiname: string | undefined
|
||||
let locationLabel: string | undefined
|
||||
let musicAlbumUrl: string | undefined
|
||||
let musicUrl: string | undefined
|
||||
let giftImageUrl: string | undefined
|
||||
let giftWish: string | undefined
|
||||
let giftPrice: string | undefined
|
||||
// 名片消息
|
||||
let cardUsername: string | undefined
|
||||
let cardNickname: string | undefined
|
||||
let cardAvatarUrl: string | undefined
|
||||
// 转账消息
|
||||
let transferPayerUsername: string | undefined
|
||||
let transferReceiverUsername: string | undefined
|
||||
@@ -1234,6 +1315,15 @@ class ChatService {
|
||||
const cardInfo = this.parseCardInfo(content)
|
||||
cardUsername = cardInfo.username
|
||||
cardNickname = cardInfo.nickname
|
||||
cardAvatarUrl = cardInfo.avatarUrl
|
||||
} else if (localType === 48 && content) {
|
||||
// 位置消息
|
||||
const latStr = this.extractXmlAttribute(content, 'location', 'x') || this.extractXmlAttribute(content, 'location', 'latitude')
|
||||
const lngStr = this.extractXmlAttribute(content, 'location', 'y') || this.extractXmlAttribute(content, 'location', 'longitude')
|
||||
if (latStr) { const v = parseFloat(latStr); if (Number.isFinite(v)) locationLat = v }
|
||||
if (lngStr) { const v = parseFloat(lngStr); if (Number.isFinite(v)) locationLng = v }
|
||||
locationLabel = this.extractXmlAttribute(content, 'location', 'label') || this.extractXmlValue(content, 'label') || undefined
|
||||
locationPoiname = this.extractXmlAttribute(content, 'location', 'poiname') || this.extractXmlValue(content, 'poiname') || undefined
|
||||
} else if ((localType === 49 || localType === 8589934592049) && content) {
|
||||
// Type 49 消息(链接、文件、小程序、转账等),8589934592049 也是转账类型
|
||||
const type49Info = this.parseType49Message(content)
|
||||
@@ -1254,6 +1344,45 @@ class ChatService {
|
||||
quotedSender = quoteInfo.sender
|
||||
}
|
||||
|
||||
const looksLikeAppMsg = Boolean(content && (content.includes('<appmsg') || content.includes('<appmsg')))
|
||||
if (looksLikeAppMsg) {
|
||||
const type49Info = this.parseType49Message(content)
|
||||
xmlType = xmlType || type49Info.xmlType
|
||||
linkTitle = linkTitle || type49Info.linkTitle
|
||||
linkUrl = linkUrl || type49Info.linkUrl
|
||||
linkThumb = linkThumb || type49Info.linkThumb
|
||||
fileName = fileName || type49Info.fileName
|
||||
fileSize = fileSize ?? type49Info.fileSize
|
||||
fileExt = fileExt || type49Info.fileExt
|
||||
appMsgKind = appMsgKind || type49Info.appMsgKind
|
||||
appMsgDesc = appMsgDesc || type49Info.appMsgDesc
|
||||
appMsgAppName = appMsgAppName || type49Info.appMsgAppName
|
||||
appMsgSourceName = appMsgSourceName || type49Info.appMsgSourceName
|
||||
appMsgSourceUsername = appMsgSourceUsername || type49Info.appMsgSourceUsername
|
||||
appMsgThumbUrl = appMsgThumbUrl || type49Info.appMsgThumbUrl
|
||||
appMsgMusicUrl = appMsgMusicUrl || type49Info.appMsgMusicUrl
|
||||
appMsgDataUrl = appMsgDataUrl || type49Info.appMsgDataUrl
|
||||
appMsgLocationLabel = appMsgLocationLabel || type49Info.appMsgLocationLabel
|
||||
finderNickname = finderNickname || type49Info.finderNickname
|
||||
finderUsername = finderUsername || type49Info.finderUsername
|
||||
finderCoverUrl = finderCoverUrl || type49Info.finderCoverUrl
|
||||
finderAvatar = finderAvatar || type49Info.finderAvatar
|
||||
finderDuration = finderDuration ?? type49Info.finderDuration
|
||||
locationLat = locationLat ?? type49Info.locationLat
|
||||
locationLng = locationLng ?? type49Info.locationLng
|
||||
locationPoiname = locationPoiname || type49Info.locationPoiname
|
||||
locationLabel = locationLabel || type49Info.locationLabel
|
||||
musicAlbumUrl = musicAlbumUrl || type49Info.musicAlbumUrl
|
||||
musicUrl = musicUrl || type49Info.musicUrl
|
||||
giftImageUrl = giftImageUrl || type49Info.giftImageUrl
|
||||
giftWish = giftWish || type49Info.giftWish
|
||||
giftPrice = giftPrice || type49Info.giftPrice
|
||||
chatRecordTitle = chatRecordTitle || type49Info.chatRecordTitle
|
||||
chatRecordList = chatRecordList || type49Info.chatRecordList
|
||||
transferPayerUsername = transferPayerUsername || type49Info.transferPayerUsername
|
||||
transferReceiverUsername = transferReceiverUsername || type49Info.transferReceiverUsername
|
||||
}
|
||||
|
||||
messages.push({
|
||||
localId: this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0),
|
||||
serverId: this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0),
|
||||
@@ -1282,8 +1411,32 @@ class ChatService {
|
||||
fileSize,
|
||||
fileExt,
|
||||
xmlType,
|
||||
appMsgKind,
|
||||
appMsgDesc,
|
||||
appMsgAppName,
|
||||
appMsgSourceName,
|
||||
appMsgSourceUsername,
|
||||
appMsgThumbUrl,
|
||||
appMsgMusicUrl,
|
||||
appMsgDataUrl,
|
||||
appMsgLocationLabel,
|
||||
finderNickname,
|
||||
finderUsername,
|
||||
finderCoverUrl,
|
||||
finderAvatar,
|
||||
finderDuration,
|
||||
locationLat,
|
||||
locationLng,
|
||||
locationPoiname,
|
||||
locationLabel,
|
||||
musicAlbumUrl,
|
||||
musicUrl,
|
||||
giftImageUrl,
|
||||
giftWish,
|
||||
giftPrice,
|
||||
cardUsername,
|
||||
cardNickname,
|
||||
cardAvatarUrl,
|
||||
transferPayerUsername,
|
||||
transferReceiverUsername,
|
||||
chatRecordTitle,
|
||||
@@ -1320,6 +1473,7 @@ class ChatService {
|
||||
|
||||
// 检查 XML type,用于识别引用消息等
|
||||
const xmlType = this.extractXmlValue(content, 'type')
|
||||
const looksLikeAppMsg = content.includes('<appmsg') || content.includes('<appmsg')
|
||||
|
||||
switch (localType) {
|
||||
case 1:
|
||||
@@ -1334,8 +1488,14 @@ class ChatService {
|
||||
return '[视频]'
|
||||
case 47:
|
||||
return '[动画表情]'
|
||||
case 48:
|
||||
return '[位置]'
|
||||
case 48: {
|
||||
const label =
|
||||
this.extractXmlAttribute(content, 'location', 'label') ||
|
||||
this.extractXmlAttribute(content, 'location', 'poiname') ||
|
||||
this.extractXmlValue(content, 'label') ||
|
||||
this.extractXmlValue(content, 'poiname')
|
||||
return label ? `[位置] ${label}` : '[位置]'
|
||||
}
|
||||
case 49:
|
||||
return this.parseType49(content)
|
||||
case 50:
|
||||
@@ -1370,6 +1530,10 @@ class ChatService {
|
||||
return title || '[引用消息]'
|
||||
}
|
||||
|
||||
if (looksLikeAppMsg) {
|
||||
return this.parseType49(content)
|
||||
}
|
||||
|
||||
// 尝试从 XML 提取通用 title
|
||||
const genericTitle = this.extractXmlValue(content, 'title')
|
||||
if (genericTitle && genericTitle.length > 0 && genericTitle.length < 100) {
|
||||
@@ -1386,6 +1550,23 @@ class ChatService {
|
||||
private parseType49(content: string): string {
|
||||
const title = this.extractXmlValue(content, 'title')
|
||||
const type = this.extractXmlValue(content, 'type')
|
||||
const normalized = content.toLowerCase()
|
||||
const locationLabel =
|
||||
this.extractXmlAttribute(content, 'location', 'label') ||
|
||||
this.extractXmlAttribute(content, 'location', 'poiname') ||
|
||||
this.extractXmlValue(content, 'label') ||
|
||||
this.extractXmlValue(content, 'poiname')
|
||||
const isFinder =
|
||||
type === '51' ||
|
||||
normalized.includes('<finder') ||
|
||||
normalized.includes('finderusername') ||
|
||||
normalized.includes('finderobjectid')
|
||||
const isRedPacket = type === '2001' || normalized.includes('hongbao')
|
||||
const isMusic =
|
||||
type === '3' ||
|
||||
normalized.includes('<musicurl>') ||
|
||||
normalized.includes('<playurl>') ||
|
||||
normalized.includes('<dataurl>')
|
||||
|
||||
// 群公告消息(type 87)特殊处理
|
||||
if (type === '87') {
|
||||
@@ -1396,6 +1577,19 @@ class ChatService {
|
||||
return '[群公告]'
|
||||
}
|
||||
|
||||
if (isFinder) {
|
||||
return title ? `[视频号] ${title}` : '[视频号]'
|
||||
}
|
||||
if (isRedPacket) {
|
||||
return title ? `[红包] ${title}` : '[红包]'
|
||||
}
|
||||
if (locationLabel) {
|
||||
return `[位置] ${locationLabel}`
|
||||
}
|
||||
if (isMusic) {
|
||||
return title ? `[音乐] ${title}` : '[音乐]'
|
||||
}
|
||||
|
||||
if (title) {
|
||||
switch (type) {
|
||||
case '5':
|
||||
@@ -1413,6 +1607,8 @@ class ChatService {
|
||||
return title
|
||||
case '2000':
|
||||
return `[转账] ${title}`
|
||||
case '2001':
|
||||
return `[红包] ${title}`
|
||||
default:
|
||||
return title
|
||||
}
|
||||
@@ -1429,6 +1625,13 @@ class ChatService {
|
||||
return '[小程序]'
|
||||
case '2000':
|
||||
return '[转账]'
|
||||
case '2001':
|
||||
return '[红包]'
|
||||
case '3':
|
||||
return '[音乐]'
|
||||
case '5':
|
||||
case '49':
|
||||
return '[链接]'
|
||||
case '87':
|
||||
return '[群公告]'
|
||||
default:
|
||||
@@ -1734,7 +1937,7 @@ class ChatService {
|
||||
* 解析名片消息
|
||||
* 格式: <msg username="wxid_xxx" nickname="昵称" ... />
|
||||
*/
|
||||
private parseCardInfo(content: string): { username?: string; nickname?: string } {
|
||||
private parseCardInfo(content: string): { username?: string; nickname?: string; avatarUrl?: string } {
|
||||
try {
|
||||
if (!content) return {}
|
||||
|
||||
@@ -1744,7 +1947,11 @@ class ChatService {
|
||||
// 提取 nickname
|
||||
const nickname = this.extractXmlAttribute(content, 'msg', 'nickname') || undefined
|
||||
|
||||
return { username, nickname }
|
||||
// 提取头像
|
||||
const avatarUrl = this.extractXmlAttribute(content, 'msg', 'bigheadimgurl') ||
|
||||
this.extractXmlAttribute(content, 'msg', 'smallheadimgurl') || undefined
|
||||
|
||||
return { username, nickname, avatarUrl }
|
||||
} catch (e) {
|
||||
console.error('[ChatService] 名片解析失败:', e)
|
||||
return {}
|
||||
@@ -1760,6 +1967,30 @@ class ChatService {
|
||||
linkTitle?: string
|
||||
linkUrl?: string
|
||||
linkThumb?: string
|
||||
appMsgKind?: string
|
||||
appMsgDesc?: string
|
||||
appMsgAppName?: string
|
||||
appMsgSourceName?: string
|
||||
appMsgSourceUsername?: string
|
||||
appMsgThumbUrl?: string
|
||||
appMsgMusicUrl?: string
|
||||
appMsgDataUrl?: string
|
||||
appMsgLocationLabel?: string
|
||||
finderNickname?: string
|
||||
finderUsername?: string
|
||||
finderCoverUrl?: string
|
||||
finderAvatar?: string
|
||||
finderDuration?: number
|
||||
locationLat?: number
|
||||
locationLng?: number
|
||||
locationPoiname?: string
|
||||
locationLabel?: string
|
||||
musicAlbumUrl?: string
|
||||
musicUrl?: string
|
||||
giftImageUrl?: string
|
||||
giftWish?: string
|
||||
giftPrice?: string
|
||||
cardAvatarUrl?: string
|
||||
fileName?: string
|
||||
fileSize?: number
|
||||
fileExt?: string
|
||||
@@ -1786,6 +2017,122 @@ class ChatService {
|
||||
// 提取通用字段
|
||||
const title = this.extractXmlValue(content, 'title')
|
||||
const url = this.extractXmlValue(content, 'url')
|
||||
const desc = this.extractXmlValue(content, 'des') || this.extractXmlValue(content, 'description')
|
||||
const appName = this.extractXmlValue(content, 'appname')
|
||||
const sourceName = this.extractXmlValue(content, 'sourcename')
|
||||
const sourceUsername = this.extractXmlValue(content, 'sourceusername')
|
||||
const thumbUrl =
|
||||
this.extractXmlValue(content, 'thumburl') ||
|
||||
this.extractXmlValue(content, 'cdnthumburl') ||
|
||||
this.extractXmlValue(content, 'cover') ||
|
||||
this.extractXmlValue(content, 'coverurl') ||
|
||||
this.extractXmlValue(content, 'thumb_url')
|
||||
const musicUrl =
|
||||
this.extractXmlValue(content, 'musicurl') ||
|
||||
this.extractXmlValue(content, 'playurl') ||
|
||||
this.extractXmlValue(content, 'songalbumurl')
|
||||
const dataUrl = this.extractXmlValue(content, 'dataurl') || this.extractXmlValue(content, 'lowurl')
|
||||
const locationLabel =
|
||||
this.extractXmlAttribute(content, 'location', 'label') ||
|
||||
this.extractXmlAttribute(content, 'location', 'poiname') ||
|
||||
this.extractXmlValue(content, 'label') ||
|
||||
this.extractXmlValue(content, 'poiname')
|
||||
const finderUsername =
|
||||
this.extractXmlValue(content, 'finderusername') ||
|
||||
this.extractXmlValue(content, 'finder_username') ||
|
||||
this.extractXmlValue(content, 'finderuser')
|
||||
const finderNickname =
|
||||
this.extractXmlValue(content, 'findernickname') ||
|
||||
this.extractXmlValue(content, 'finder_nickname')
|
||||
const normalized = content.toLowerCase()
|
||||
const isFinder = xmlType === '51'
|
||||
const isRedPacket = xmlType === '2001'
|
||||
const isMusic = xmlType === '3'
|
||||
const isLocation = Boolean(locationLabel)
|
||||
|
||||
result.linkTitle = title || undefined
|
||||
result.linkUrl = url || undefined
|
||||
result.linkThumb = thumbUrl || undefined
|
||||
result.appMsgDesc = desc || undefined
|
||||
result.appMsgAppName = appName || undefined
|
||||
result.appMsgSourceName = sourceName || undefined
|
||||
result.appMsgSourceUsername = sourceUsername || undefined
|
||||
result.appMsgThumbUrl = thumbUrl || undefined
|
||||
result.appMsgMusicUrl = musicUrl || undefined
|
||||
result.appMsgDataUrl = dataUrl || undefined
|
||||
result.appMsgLocationLabel = locationLabel || undefined
|
||||
result.finderUsername = finderUsername || undefined
|
||||
result.finderNickname = finderNickname || undefined
|
||||
|
||||
// 视频号封面/头像/时长
|
||||
if (isFinder) {
|
||||
const finderCover =
|
||||
this.extractXmlValue(content, 'thumbUrl') ||
|
||||
this.extractXmlValue(content, 'coverUrl') ||
|
||||
this.extractXmlValue(content, 'thumburl') ||
|
||||
this.extractXmlValue(content, 'coverurl')
|
||||
if (finderCover) result.finderCoverUrl = finderCover
|
||||
const finderAvatar = this.extractXmlValue(content, 'avatar')
|
||||
if (finderAvatar) result.finderAvatar = finderAvatar
|
||||
const durationStr = this.extractXmlValue(content, 'videoPlayDuration') || this.extractXmlValue(content, 'duration')
|
||||
if (durationStr) {
|
||||
const d = parseInt(durationStr, 10)
|
||||
if (Number.isFinite(d) && d > 0) result.finderDuration = d
|
||||
}
|
||||
}
|
||||
|
||||
// 位置经纬度
|
||||
if (isLocation) {
|
||||
const latAttr = this.extractXmlAttribute(content, 'location', 'x') || this.extractXmlAttribute(content, 'location', 'latitude')
|
||||
const lngAttr = this.extractXmlAttribute(content, 'location', 'y') || this.extractXmlAttribute(content, 'location', 'longitude')
|
||||
if (latAttr) { const v = parseFloat(latAttr); if (Number.isFinite(v)) result.locationLat = v }
|
||||
if (lngAttr) { const v = parseFloat(lngAttr); if (Number.isFinite(v)) result.locationLng = v }
|
||||
result.locationPoiname = this.extractXmlAttribute(content, 'location', 'poiname') || locationLabel || undefined
|
||||
result.locationLabel = this.extractXmlAttribute(content, 'location', 'label') || undefined
|
||||
}
|
||||
|
||||
// 音乐专辑封面
|
||||
if (isMusic) {
|
||||
const albumUrl = this.extractXmlValue(content, 'songalbumurl')
|
||||
if (albumUrl) result.musicAlbumUrl = albumUrl
|
||||
result.musicUrl = musicUrl || dataUrl || url || undefined
|
||||
}
|
||||
|
||||
// 礼物消息
|
||||
const isGift = xmlType === '115'
|
||||
if (isGift) {
|
||||
result.giftWish = this.extractXmlValue(content, 'wishmessage') || undefined
|
||||
result.giftImageUrl = this.extractXmlValue(content, 'skuimgurl') || undefined
|
||||
result.giftPrice = this.extractXmlValue(content, 'skuprice') || undefined
|
||||
}
|
||||
|
||||
if (isFinder) {
|
||||
result.appMsgKind = 'finder'
|
||||
} else if (isRedPacket) {
|
||||
result.appMsgKind = 'red-packet'
|
||||
} else if (isGift) {
|
||||
result.appMsgKind = 'gift'
|
||||
} else if (isLocation) {
|
||||
result.appMsgKind = 'location'
|
||||
} else if (isMusic) {
|
||||
result.appMsgKind = 'music'
|
||||
} else if (xmlType === '33' || xmlType === '36') {
|
||||
result.appMsgKind = 'miniapp'
|
||||
} else if (xmlType === '6') {
|
||||
result.appMsgKind = 'file'
|
||||
} else if (xmlType === '19') {
|
||||
result.appMsgKind = 'chat-record'
|
||||
} else if (xmlType === '2000') {
|
||||
result.appMsgKind = 'transfer'
|
||||
} else if (xmlType === '87') {
|
||||
result.appMsgKind = 'announcement'
|
||||
} else if ((xmlType === '5' || xmlType === '49') && (sourceUsername?.startsWith('gh_') || appName?.includes('公众号') || sourceName)) {
|
||||
result.appMsgKind = 'official-link'
|
||||
} else if (url) {
|
||||
result.appMsgKind = 'link'
|
||||
} else {
|
||||
result.appMsgKind = 'card'
|
||||
}
|
||||
|
||||
switch (xmlType) {
|
||||
case '6': {
|
||||
@@ -3720,10 +4067,7 @@ class ChatService {
|
||||
|
||||
private cacheVoiceWav(cacheKey: string, wavData: Buffer): void {
|
||||
this.voiceWavCache.set(cacheKey, wavData)
|
||||
if (this.voiceWavCache.size > this.voiceWavCacheMaxEntries) {
|
||||
const oldestKey = this.voiceWavCache.keys().next().value
|
||||
if (oldestKey) this.voiceWavCache.delete(oldestKey)
|
||||
}
|
||||
// LRU缓存会自动处理大小限制,无需手动清理
|
||||
}
|
||||
|
||||
/** 获取持久化转写缓存文件路径 */
|
||||
@@ -3857,6 +4201,74 @@ class ChatService {
|
||||
* 获取某会话中有消息的日期列表
|
||||
* 返回 YYYY-MM-DD 格式的日期字符串数组
|
||||
*/
|
||||
/**
|
||||
* 获取某会话的全部图片消息(用于聊天页批量图片解密)
|
||||
*/
|
||||
async getAllImageMessages(
|
||||
sessionId: string
|
||||
): Promise<{ success: boolean; images?: { imageMd5?: string; imageDatName?: string; createTime?: number }[]; error?: string }> {
|
||||
try {
|
||||
const connectResult = await this.ensureConnected()
|
||||
if (!connectResult.success) {
|
||||
return { success: false, error: connectResult.error || '数据库未连接' }
|
||||
}
|
||||
|
||||
let tables = this.sessionTablesCache.get(sessionId)
|
||||
if (!tables) {
|
||||
const tableStats = await wcdbService.getMessageTableStats(sessionId)
|
||||
if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) {
|
||||
return { success: false, error: '未找到会话消息表' }
|
||||
}
|
||||
tables = tableStats.tables
|
||||
.map(t => ({ tableName: t.table_name || t.name, dbPath: t.db_path }))
|
||||
.filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }>
|
||||
if (tables.length > 0) {
|
||||
this.sessionTablesCache.set(sessionId, tables)
|
||||
setTimeout(() => { this.sessionTablesCache.delete(sessionId) }, this.sessionTablesCacheTtl)
|
||||
}
|
||||
}
|
||||
|
||||
let allImages: Array<{ imageMd5?: string; imageDatName?: string; createTime?: number }> = []
|
||||
|
||||
for (const { tableName, dbPath } of tables) {
|
||||
try {
|
||||
const sql = `SELECT * FROM ${tableName} WHERE local_type = 3 ORDER BY create_time DESC`
|
||||
const result = await wcdbService.execQuery('message', dbPath, sql)
|
||||
if (result.success && result.rows && result.rows.length > 0) {
|
||||
const mapped = this.mapRowsToMessages(result.rows as Record<string, any>[])
|
||||
const images = mapped
|
||||
.filter(msg => msg.localType === 3)
|
||||
.map(msg => ({
|
||||
imageMd5: msg.imageMd5 || undefined,
|
||||
imageDatName: msg.imageDatName || undefined,
|
||||
createTime: msg.createTime || undefined
|
||||
}))
|
||||
.filter(img => Boolean(img.imageMd5 || img.imageDatName))
|
||||
allImages.push(...images)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[ChatService] 查询图片消息失败 (${dbPath}):`, e)
|
||||
}
|
||||
}
|
||||
|
||||
allImages.sort((a, b) => (b.createTime || 0) - (a.createTime || 0))
|
||||
|
||||
const seen = new Set<string>()
|
||||
allImages = allImages.filter(img => {
|
||||
const key = img.imageMd5 || img.imageDatName || ''
|
||||
if (!key || seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
|
||||
console.log(`[ChatService] 共找到 ${allImages.length} 条图片消息(去重后)`)
|
||||
return { success: true, images: allImages }
|
||||
} catch (e) {
|
||||
console.error('[ChatService] 获取全部图片消息失败:', e)
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getMessageDates(sessionId: string): Promise<{ success: boolean; dates?: string[]; error?: string }> {
|
||||
try {
|
||||
const connectResult = await this.ensureConnected()
|
||||
@@ -3990,6 +4402,15 @@ class ChatService {
|
||||
msg.emojiThumbUrl = emojiInfo.thumbUrl
|
||||
msg.emojiEncryptUrl = emojiInfo.encryptUrl
|
||||
msg.emojiAesKey = emojiInfo.aesKey
|
||||
} else if (msg.localType === 42) {
|
||||
const cardInfo = this.parseCardInfo(rawContent)
|
||||
msg.cardUsername = cardInfo.username
|
||||
msg.cardNickname = cardInfo.nickname
|
||||
msg.cardAvatarUrl = cardInfo.avatarUrl
|
||||
}
|
||||
|
||||
if (rawContent && (rawContent.includes('<appmsg') || rawContent.includes('<appmsg'))) {
|
||||
Object.assign(msg, this.parseType49Message(rawContent))
|
||||
}
|
||||
|
||||
return msg
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { join } from 'path'
|
||||
import { app } from 'electron'
|
||||
import { app, safeStorage } from 'electron'
|
||||
import crypto from 'crypto'
|
||||
import Store from 'electron-store'
|
||||
|
||||
// 加密前缀标记
|
||||
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
|
||||
const LOCK_PREFIX = 'lock:' // 密码派生密钥加密(锁定模式)
|
||||
|
||||
interface ConfigSchema {
|
||||
// 数据库相关
|
||||
dbPath: string // 数据库根目录 (xwechat_files)
|
||||
decryptKey: string // 解密密钥
|
||||
myWxid: string // 当前用户 wxid
|
||||
dbPath: string
|
||||
decryptKey: string
|
||||
myWxid: string
|
||||
onboardingDone: boolean
|
||||
imageXorKey: number
|
||||
imageAesKey: string
|
||||
@@ -14,7 +19,6 @@ interface ConfigSchema {
|
||||
|
||||
// 缓存相关
|
||||
cachePath: string
|
||||
|
||||
lastOpenedDb: string
|
||||
lastSession: string
|
||||
|
||||
@@ -34,8 +38,9 @@ interface ConfigSchema {
|
||||
|
||||
// 安全相关
|
||||
authEnabled: boolean
|
||||
authPassword: string // SHA-256 hash
|
||||
authPassword: string // SHA-256 hash(safeStorage 加密)
|
||||
authUseHello: boolean
|
||||
authHelloSecret: string // 原始密码(safeStorage 加密,Hello 解锁时使用)
|
||||
|
||||
// 更新相关
|
||||
ignoredUpdateVersion: string
|
||||
@@ -48,10 +53,23 @@ interface ConfigSchema {
|
||||
wordCloudExcludeWords: string[]
|
||||
}
|
||||
|
||||
// 需要 safeStorage 加密的字段(普通模式)
|
||||
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword'])
|
||||
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
|
||||
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
||||
|
||||
// 需要与密码绑定的敏感密钥字段(锁定模式时用 lock: 加密)
|
||||
const LOCKABLE_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey'])
|
||||
const LOCKABLE_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
||||
|
||||
export class ConfigService {
|
||||
private static instance: ConfigService
|
||||
private store!: Store<ConfigSchema>
|
||||
|
||||
// 锁定模式运行时状态
|
||||
private unlockedKeys: Map<string, any> = new Map()
|
||||
private unlockPassword: string | null = null
|
||||
|
||||
static getInstance(): ConfigService {
|
||||
if (!ConfigService.instance) {
|
||||
ConfigService.instance = new ConfigService()
|
||||
@@ -75,7 +93,6 @@ export class ConfigService {
|
||||
imageAesKey: '',
|
||||
wxidConfigs: {},
|
||||
cachePath: '',
|
||||
|
||||
lastOpenedDb: '',
|
||||
lastSession: '',
|
||||
theme: 'system',
|
||||
@@ -90,11 +107,10 @@ export class ConfigService {
|
||||
transcribeLanguages: ['zh'],
|
||||
exportDefaultConcurrency: 2,
|
||||
analyticsExcludedUsernames: [],
|
||||
|
||||
authEnabled: false,
|
||||
authPassword: '',
|
||||
authUseHello: false,
|
||||
|
||||
authHelloSecret: '',
|
||||
ignoredUpdateVersion: '',
|
||||
notificationEnabled: true,
|
||||
notificationPosition: 'top-right',
|
||||
@@ -103,29 +119,535 @@ export class ConfigService {
|
||||
wordCloudExcludeWords: []
|
||||
}
|
||||
})
|
||||
this.migrateAuthFields()
|
||||
}
|
||||
|
||||
// === 状态查询 ===
|
||||
|
||||
isLockMode(): boolean {
|
||||
const raw: any = this.store.get('decryptKey')
|
||||
return typeof raw === 'string' && raw.startsWith(LOCK_PREFIX)
|
||||
}
|
||||
|
||||
isUnlocked(): boolean {
|
||||
return !this.isLockMode() || this.unlockedKeys.size > 0
|
||||
}
|
||||
|
||||
// === get / set ===
|
||||
|
||||
get<K extends keyof ConfigSchema>(key: K): ConfigSchema[K] {
|
||||
return this.store.get(key)
|
||||
const raw = this.store.get(key)
|
||||
|
||||
if (ENCRYPTED_BOOL_KEYS.has(key)) {
|
||||
const str = typeof raw === 'string' ? raw : ''
|
||||
if (!str || !str.startsWith(SAFE_PREFIX)) return raw
|
||||
return (this.safeDecrypt(str) === 'true') as ConfigSchema[K]
|
||||
}
|
||||
|
||||
if (ENCRYPTED_NUMBER_KEYS.has(key)) {
|
||||
const str = typeof raw === 'string' ? raw : ''
|
||||
if (!str) return raw
|
||||
if (str.startsWith(LOCK_PREFIX)) {
|
||||
const cached = this.unlockedKeys.get(key as string)
|
||||
return (cached !== undefined ? cached : 0) as ConfigSchema[K]
|
||||
}
|
||||
if (!str.startsWith(SAFE_PREFIX)) return raw
|
||||
const num = Number(this.safeDecrypt(str))
|
||||
return (Number.isFinite(num) ? num : 0) as ConfigSchema[K]
|
||||
}
|
||||
|
||||
if (ENCRYPTED_STRING_KEYS.has(key) && typeof raw === 'string') {
|
||||
if (key === 'authPassword') return this.safeDecrypt(raw) as ConfigSchema[K]
|
||||
if (raw.startsWith(LOCK_PREFIX)) {
|
||||
const cached = this.unlockedKeys.get(key as string)
|
||||
return (cached !== undefined ? cached : '') as ConfigSchema[K]
|
||||
}
|
||||
return this.safeDecrypt(raw) as ConfigSchema[K]
|
||||
}
|
||||
|
||||
if (key === 'wxidConfigs' && raw && typeof raw === 'object') {
|
||||
return this.decryptWxidConfigs(raw as any) as ConfigSchema[K]
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
set<K extends keyof ConfigSchema>(key: K, value: ConfigSchema[K]): void {
|
||||
this.store.set(key, value)
|
||||
let toStore = value
|
||||
const inLockMode = this.isLockMode() && this.unlockPassword
|
||||
|
||||
if (ENCRYPTED_BOOL_KEYS.has(key)) {
|
||||
toStore = this.safeEncrypt(String(value)) as ConfigSchema[K]
|
||||
} else if (ENCRYPTED_NUMBER_KEYS.has(key)) {
|
||||
if (inLockMode && LOCKABLE_NUMBER_KEYS.has(key)) {
|
||||
toStore = this.lockEncrypt(String(value), this.unlockPassword!) as ConfigSchema[K]
|
||||
this.unlockedKeys.set(key as string, value)
|
||||
} else {
|
||||
toStore = this.safeEncrypt(String(value)) as ConfigSchema[K]
|
||||
}
|
||||
} else if (ENCRYPTED_STRING_KEYS.has(key) && typeof value === 'string') {
|
||||
if (key === 'authPassword') {
|
||||
toStore = this.safeEncrypt(value) as ConfigSchema[K]
|
||||
} else if (inLockMode && LOCKABLE_STRING_KEYS.has(key)) {
|
||||
toStore = this.lockEncrypt(value, this.unlockPassword!) as ConfigSchema[K]
|
||||
this.unlockedKeys.set(key as string, value)
|
||||
} else {
|
||||
toStore = this.safeEncrypt(value) as ConfigSchema[K]
|
||||
}
|
||||
} else if (key === 'wxidConfigs' && value && typeof value === 'object') {
|
||||
if (inLockMode) {
|
||||
toStore = this.lockEncryptWxidConfigs(value as any) as ConfigSchema[K]
|
||||
} else {
|
||||
toStore = this.encryptWxidConfigs(value as any) as ConfigSchema[K]
|
||||
}
|
||||
}
|
||||
|
||||
this.store.set(key, toStore)
|
||||
}
|
||||
|
||||
// === 加密/解密工具 ===
|
||||
|
||||
private safeEncrypt(plaintext: string): string {
|
||||
if (!plaintext) return ''
|
||||
if (plaintext.startsWith(SAFE_PREFIX)) return plaintext
|
||||
if (!safeStorage.isEncryptionAvailable()) return plaintext
|
||||
const encrypted = safeStorage.encryptString(plaintext)
|
||||
return SAFE_PREFIX + encrypted.toString('base64')
|
||||
}
|
||||
|
||||
private safeDecrypt(stored: string): string {
|
||||
if (!stored) return ''
|
||||
if (!stored.startsWith(SAFE_PREFIX)) return stored
|
||||
if (!safeStorage.isEncryptionAvailable()) return ''
|
||||
try {
|
||||
const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64')
|
||||
return safeStorage.decryptString(buf)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
private lockEncrypt(plaintext: string, password: string): string {
|
||||
if (!plaintext) return ''
|
||||
const salt = crypto.randomBytes(16)
|
||||
const iv = crypto.randomBytes(12)
|
||||
const derivedKey = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256')
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', derivedKey, iv)
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
||||
const authTag = cipher.getAuthTag()
|
||||
const combined = Buffer.concat([salt, iv, authTag, encrypted])
|
||||
return LOCK_PREFIX + combined.toString('base64')
|
||||
}
|
||||
|
||||
private lockDecrypt(stored: string, password: string): string | null {
|
||||
if (!stored || !stored.startsWith(LOCK_PREFIX)) return null
|
||||
try {
|
||||
const combined = Buffer.from(stored.slice(LOCK_PREFIX.length), 'base64')
|
||||
const salt = combined.subarray(0, 16)
|
||||
const iv = combined.subarray(16, 28)
|
||||
const authTag = combined.subarray(28, 44)
|
||||
const ciphertext = combined.subarray(44)
|
||||
const derivedKey = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256')
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', derivedKey, iv)
|
||||
decipher.setAuthTag(authTag)
|
||||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||
return decrypted.toString('utf8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 通过尝试解密 lock: 字段来验证密码是否正确(当 authPassword 被删除时使用)
|
||||
private verifyPasswordByDecrypt(password: string): boolean {
|
||||
// 依次尝试解密任意一个 lock: 字段,GCM authTag 会验证密码正确性
|
||||
const lockFields = ['decryptKey', 'imageAesKey', 'imageXorKey'] as const
|
||||
for (const key of lockFields) {
|
||||
const raw: any = this.store.get(key as any)
|
||||
if (typeof raw === 'string' && raw.startsWith(LOCK_PREFIX)) {
|
||||
const result = this.lockDecrypt(raw, password)
|
||||
// lockDecrypt 返回 null 表示解密失败(密码错误),非 null 表示成功
|
||||
return result !== null
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// === wxidConfigs 加密/解密 ===
|
||||
|
||||
private encryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] {
|
||||
const result: ConfigSchema['wxidConfigs'] = {}
|
||||
for (const [wxid, cfg] of Object.entries(configs)) {
|
||||
result[wxid] = { ...cfg }
|
||||
if (cfg.decryptKey) result[wxid].decryptKey = this.safeEncrypt(cfg.decryptKey)
|
||||
if (cfg.imageAesKey) result[wxid].imageAesKey = this.safeEncrypt(cfg.imageAesKey)
|
||||
if (cfg.imageXorKey !== undefined) {
|
||||
(result[wxid] as any).imageXorKey = this.safeEncrypt(String(cfg.imageXorKey))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private decryptLockedWxidConfigs(password: string): void {
|
||||
const wxidConfigs = this.store.get('wxidConfigs')
|
||||
if (!wxidConfigs || typeof wxidConfigs !== 'object') return
|
||||
for (const [wxid, cfg] of Object.entries(wxidConfigs) as [string, any][]) {
|
||||
if (cfg.decryptKey && typeof cfg.decryptKey === 'string' && cfg.decryptKey.startsWith(LOCK_PREFIX)) {
|
||||
const d = this.lockDecrypt(cfg.decryptKey, password)
|
||||
if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:decryptKey`, d)
|
||||
}
|
||||
if (cfg.imageAesKey && typeof cfg.imageAesKey === 'string' && cfg.imageAesKey.startsWith(LOCK_PREFIX)) {
|
||||
const d = this.lockDecrypt(cfg.imageAesKey, password)
|
||||
if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:imageAesKey`, d)
|
||||
}
|
||||
if (cfg.imageXorKey && typeof cfg.imageXorKey === 'string' && cfg.imageXorKey.startsWith(LOCK_PREFIX)) {
|
||||
const d = this.lockDecrypt(cfg.imageXorKey, password)
|
||||
if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:imageXorKey`, Number(d))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private decryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] {
|
||||
const result: ConfigSchema['wxidConfigs'] = {}
|
||||
for (const [wxid, cfg] of Object.entries(configs) as [string, any][]) {
|
||||
result[wxid] = { ...cfg, updatedAt: cfg.updatedAt }
|
||||
// decryptKey
|
||||
if (typeof cfg.decryptKey === 'string') {
|
||||
if (cfg.decryptKey.startsWith(LOCK_PREFIX)) {
|
||||
result[wxid].decryptKey = this.unlockedKeys.get(`wxid:${wxid}:decryptKey`) ?? ''
|
||||
} else {
|
||||
result[wxid].decryptKey = this.safeDecrypt(cfg.decryptKey)
|
||||
}
|
||||
}
|
||||
// imageAesKey
|
||||
if (typeof cfg.imageAesKey === 'string') {
|
||||
if (cfg.imageAesKey.startsWith(LOCK_PREFIX)) {
|
||||
result[wxid].imageAesKey = this.unlockedKeys.get(`wxid:${wxid}:imageAesKey`) ?? ''
|
||||
} else {
|
||||
result[wxid].imageAesKey = this.safeDecrypt(cfg.imageAesKey)
|
||||
}
|
||||
}
|
||||
// imageXorKey
|
||||
if (typeof cfg.imageXorKey === 'string') {
|
||||
if (cfg.imageXorKey.startsWith(LOCK_PREFIX)) {
|
||||
result[wxid].imageXorKey = this.unlockedKeys.get(`wxid:${wxid}:imageXorKey`) ?? 0
|
||||
} else if (cfg.imageXorKey.startsWith(SAFE_PREFIX)) {
|
||||
const num = Number(this.safeDecrypt(cfg.imageXorKey))
|
||||
result[wxid].imageXorKey = Number.isFinite(num) ? num : 0
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
private lockEncryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] {
|
||||
const result: ConfigSchema['wxidConfigs'] = {}
|
||||
for (const [wxid, cfg] of Object.entries(configs)) {
|
||||
result[wxid] = { ...cfg }
|
||||
if (cfg.decryptKey) result[wxid].decryptKey = this.lockEncrypt(cfg.decryptKey, this.unlockPassword!) as any
|
||||
if (cfg.imageAesKey) result[wxid].imageAesKey = this.lockEncrypt(cfg.imageAesKey, this.unlockPassword!) as any
|
||||
if (cfg.imageXorKey !== undefined) {
|
||||
(result[wxid] as any).imageXorKey = this.lockEncrypt(String(cfg.imageXorKey), this.unlockPassword!)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// === 业务方法 ===
|
||||
|
||||
enableLock(password: string): { success: boolean; error?: string } {
|
||||
try {
|
||||
// 先读取当前所有明文密钥
|
||||
const decryptKey = this.get('decryptKey')
|
||||
const imageAesKey = this.get('imageAesKey')
|
||||
const imageXorKey = this.get('imageXorKey')
|
||||
const wxidConfigs = this.get('wxidConfigs')
|
||||
|
||||
// 存储密码 hash(safeStorage 加密)
|
||||
const passwordHash = crypto.createHash('sha256').update(password).digest('hex')
|
||||
this.store.set('authPassword', this.safeEncrypt(passwordHash) as any)
|
||||
this.store.set('authEnabled', this.safeEncrypt('true') as any)
|
||||
|
||||
// 设置运行时状态
|
||||
this.unlockPassword = password
|
||||
this.unlockedKeys.set('decryptKey', decryptKey)
|
||||
this.unlockedKeys.set('imageAesKey', imageAesKey)
|
||||
this.unlockedKeys.set('imageXorKey', imageXorKey)
|
||||
|
||||
// 用密码派生密钥重新加密所有敏感字段
|
||||
if (decryptKey) this.store.set('decryptKey', this.lockEncrypt(String(decryptKey), password) as any)
|
||||
if (imageAesKey) this.store.set('imageAesKey', this.lockEncrypt(String(imageAesKey), password) as any)
|
||||
if (imageXorKey !== undefined) this.store.set('imageXorKey', this.lockEncrypt(String(imageXorKey), password) as any)
|
||||
|
||||
// 处理 wxidConfigs 中的嵌套密钥
|
||||
if (wxidConfigs && Object.keys(wxidConfigs).length > 0) {
|
||||
const lockedConfigs = this.lockEncryptWxidConfigs(wxidConfigs)
|
||||
this.store.set('wxidConfigs', lockedConfigs)
|
||||
for (const [wxid, cfg] of Object.entries(wxidConfigs)) {
|
||||
if (cfg.decryptKey) this.unlockedKeys.set(`wxid:${wxid}:decryptKey`, cfg.decryptKey)
|
||||
if (cfg.imageAesKey) this.unlockedKeys.set(`wxid:${wxid}:imageAesKey`, cfg.imageAesKey)
|
||||
if (cfg.imageXorKey !== undefined) this.unlockedKeys.set(`wxid:${wxid}:imageXorKey`, cfg.imageXorKey)
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
unlock(password: string): { success: boolean; error?: string } {
|
||||
try {
|
||||
// 验证密码
|
||||
const storedHash = this.safeDecrypt(this.store.get('authPassword') as any)
|
||||
const inputHash = crypto.createHash('sha256').update(password).digest('hex')
|
||||
|
||||
if (storedHash && storedHash !== inputHash) {
|
||||
// authPassword 存在但密码不匹配
|
||||
return { success: false, error: '密码错误' }
|
||||
}
|
||||
|
||||
if (!storedHash) {
|
||||
// authPassword 被删除/损坏,尝试用密码直接解密 lock: 字段来验证
|
||||
const verified = this.verifyPasswordByDecrypt(password)
|
||||
if (!verified) {
|
||||
return { success: false, error: '密码错误' }
|
||||
}
|
||||
// 密码正确,自愈 authPassword
|
||||
const newHash = crypto.createHash('sha256').update(password).digest('hex')
|
||||
this.store.set('authPassword', this.safeEncrypt(newHash) as any)
|
||||
this.store.set('authEnabled', this.safeEncrypt('true') as any)
|
||||
}
|
||||
|
||||
// 解密所有 lock: 字段到内存缓存
|
||||
const rawDecryptKey: any = this.store.get('decryptKey')
|
||||
if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) {
|
||||
const d = this.lockDecrypt(rawDecryptKey, password)
|
||||
if (d !== null) this.unlockedKeys.set('decryptKey', d)
|
||||
}
|
||||
|
||||
const rawImageAesKey: any = this.store.get('imageAesKey')
|
||||
if (typeof rawImageAesKey === 'string' && rawImageAesKey.startsWith(LOCK_PREFIX)) {
|
||||
const d = this.lockDecrypt(rawImageAesKey, password)
|
||||
if (d !== null) this.unlockedKeys.set('imageAesKey', d)
|
||||
}
|
||||
|
||||
const rawImageXorKey: any = this.store.get('imageXorKey')
|
||||
if (typeof rawImageXorKey === 'string' && rawImageXorKey.startsWith(LOCK_PREFIX)) {
|
||||
const d = this.lockDecrypt(rawImageXorKey, password)
|
||||
if (d !== null) this.unlockedKeys.set('imageXorKey', Number(d))
|
||||
}
|
||||
|
||||
// 解密 wxidConfigs 嵌套密钥
|
||||
this.decryptLockedWxidConfigs(password)
|
||||
|
||||
// 保留密码供 set() 使用
|
||||
this.unlockPassword = password
|
||||
return { success: true }
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
disableLock(password: string): { success: boolean; error?: string } {
|
||||
try {
|
||||
// 验证密码
|
||||
const storedHash = this.safeDecrypt(this.store.get('authPassword') as any)
|
||||
const inputHash = crypto.createHash('sha256').update(password).digest('hex')
|
||||
if (storedHash !== inputHash) {
|
||||
return { success: false, error: '密码错误' }
|
||||
}
|
||||
|
||||
// 先解密所有 lock: 字段
|
||||
if (this.unlockedKeys.size === 0) {
|
||||
this.unlock(password)
|
||||
}
|
||||
|
||||
// 将所有密钥转回 safe: 格式
|
||||
const decryptKey = this.unlockedKeys.get('decryptKey')
|
||||
const imageAesKey = this.unlockedKeys.get('imageAesKey')
|
||||
const imageXorKey = this.unlockedKeys.get('imageXorKey')
|
||||
|
||||
if (decryptKey) this.store.set('decryptKey', this.safeEncrypt(String(decryptKey)) as any)
|
||||
if (imageAesKey) this.store.set('imageAesKey', this.safeEncrypt(String(imageAesKey)) as any)
|
||||
if (imageXorKey !== undefined) this.store.set('imageXorKey', this.safeEncrypt(String(imageXorKey)) as any)
|
||||
|
||||
// 转换 wxidConfigs
|
||||
const wxidConfigs = this.get('wxidConfigs')
|
||||
if (wxidConfigs && Object.keys(wxidConfigs).length > 0) {
|
||||
const safeConfigs = this.encryptWxidConfigs(wxidConfigs)
|
||||
this.store.set('wxidConfigs', safeConfigs)
|
||||
}
|
||||
|
||||
// 清除 auth 字段
|
||||
this.store.set('authEnabled', false as any)
|
||||
this.store.set('authPassword', '' as any)
|
||||
this.store.set('authUseHello', false as any)
|
||||
this.store.set('authHelloSecret', '' as any)
|
||||
|
||||
// 清除运行时状态
|
||||
this.unlockedKeys.clear()
|
||||
this.unlockPassword = null
|
||||
|
||||
return { success: true }
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
changePassword(oldPassword: string, newPassword: string): { success: boolean; error?: string } {
|
||||
try {
|
||||
// 验证旧密码
|
||||
const storedHash = this.safeDecrypt(this.store.get('authPassword') as any)
|
||||
const oldHash = crypto.createHash('sha256').update(oldPassword).digest('hex')
|
||||
if (storedHash !== oldHash) {
|
||||
return { success: false, error: '旧密码错误' }
|
||||
}
|
||||
|
||||
// 确保已解锁
|
||||
if (this.unlockedKeys.size === 0) {
|
||||
this.unlock(oldPassword)
|
||||
}
|
||||
|
||||
// 用新密码重新加密所有密钥
|
||||
const decryptKey = this.unlockedKeys.get('decryptKey')
|
||||
const imageAesKey = this.unlockedKeys.get('imageAesKey')
|
||||
const imageXorKey = this.unlockedKeys.get('imageXorKey')
|
||||
|
||||
if (decryptKey) this.store.set('decryptKey', this.lockEncrypt(String(decryptKey), newPassword) as any)
|
||||
if (imageAesKey) this.store.set('imageAesKey', this.lockEncrypt(String(imageAesKey), newPassword) as any)
|
||||
if (imageXorKey !== undefined) this.store.set('imageXorKey', this.lockEncrypt(String(imageXorKey), newPassword) as any)
|
||||
|
||||
// 重新加密 wxidConfigs
|
||||
const wxidConfigs = this.get('wxidConfigs')
|
||||
if (wxidConfigs && Object.keys(wxidConfigs).length > 0) {
|
||||
this.unlockPassword = newPassword
|
||||
const lockedConfigs = this.lockEncryptWxidConfigs(wxidConfigs)
|
||||
this.store.set('wxidConfigs', lockedConfigs)
|
||||
}
|
||||
|
||||
// 更新密码 hash
|
||||
const newHash = crypto.createHash('sha256').update(newPassword).digest('hex')
|
||||
this.store.set('authPassword', this.safeEncrypt(newHash) as any)
|
||||
|
||||
// 更新 Hello secret(如果启用了 Hello)
|
||||
const useHello = this.get('authUseHello')
|
||||
if (useHello) {
|
||||
this.store.set('authHelloSecret', this.safeEncrypt(newPassword) as any)
|
||||
}
|
||||
|
||||
this.unlockPassword = newPassword
|
||||
return { success: true }
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
// === Hello 相关 ===
|
||||
|
||||
setHelloSecret(password: string): void {
|
||||
this.store.set('authHelloSecret', this.safeEncrypt(password) as any)
|
||||
this.store.set('authUseHello', this.safeEncrypt('true') as any)
|
||||
}
|
||||
|
||||
getHelloSecret(): string {
|
||||
const raw: any = this.store.get('authHelloSecret')
|
||||
if (!raw || typeof raw !== 'string') return ''
|
||||
return this.safeDecrypt(raw)
|
||||
}
|
||||
|
||||
clearHelloSecret(): void {
|
||||
this.store.set('authHelloSecret', '' as any)
|
||||
this.store.set('authUseHello', this.safeEncrypt('false') as any)
|
||||
}
|
||||
|
||||
// === 迁移 ===
|
||||
|
||||
private migrateAuthFields(): void {
|
||||
// 将旧版明文 auth 字段迁移为 safeStorage 加密格式
|
||||
// 如果已经是 safe: 或 lock: 前缀则跳过
|
||||
const rawEnabled: any = this.store.get('authEnabled')
|
||||
if (typeof rawEnabled === 'boolean') {
|
||||
this.store.set('authEnabled', this.safeEncrypt(String(rawEnabled)) as any)
|
||||
}
|
||||
|
||||
const rawUseHello: any = this.store.get('authUseHello')
|
||||
if (typeof rawUseHello === 'boolean') {
|
||||
this.store.set('authUseHello', this.safeEncrypt(String(rawUseHello)) as any)
|
||||
}
|
||||
|
||||
const rawPassword: any = this.store.get('authPassword')
|
||||
if (typeof rawPassword === 'string' && rawPassword && !rawPassword.startsWith(SAFE_PREFIX)) {
|
||||
this.store.set('authPassword', this.safeEncrypt(rawPassword) as any)
|
||||
}
|
||||
|
||||
// 迁移敏感密钥字段(明文 → safe:)
|
||||
for (const key of LOCKABLE_STRING_KEYS) {
|
||||
const raw: any = this.store.get(key as any)
|
||||
if (typeof raw === 'string' && raw && !raw.startsWith(SAFE_PREFIX) && !raw.startsWith(LOCK_PREFIX)) {
|
||||
this.store.set(key as any, this.safeEncrypt(raw) as any)
|
||||
}
|
||||
}
|
||||
|
||||
// imageXorKey: 数字 → safe:
|
||||
const rawXor: any = this.store.get('imageXorKey')
|
||||
if (typeof rawXor === 'number' && rawXor !== 0) {
|
||||
this.store.set('imageXorKey', this.safeEncrypt(String(rawXor)) as any)
|
||||
}
|
||||
|
||||
// wxidConfigs 中的嵌套密钥
|
||||
const wxidConfigs: any = this.store.get('wxidConfigs')
|
||||
if (wxidConfigs && typeof wxidConfigs === 'object') {
|
||||
let changed = false
|
||||
for (const [_wxid, cfg] of Object.entries(wxidConfigs) as [string, any][]) {
|
||||
if (cfg.decryptKey && typeof cfg.decryptKey === 'string' && !cfg.decryptKey.startsWith(SAFE_PREFIX) && !cfg.decryptKey.startsWith(LOCK_PREFIX)) {
|
||||
cfg.decryptKey = this.safeEncrypt(cfg.decryptKey)
|
||||
changed = true
|
||||
}
|
||||
if (cfg.imageAesKey && typeof cfg.imageAesKey === 'string' && !cfg.imageAesKey.startsWith(SAFE_PREFIX) && !cfg.imageAesKey.startsWith(LOCK_PREFIX)) {
|
||||
cfg.imageAesKey = this.safeEncrypt(cfg.imageAesKey)
|
||||
changed = true
|
||||
}
|
||||
if (typeof cfg.imageXorKey === 'number' && cfg.imageXorKey !== 0) {
|
||||
cfg.imageXorKey = this.safeEncrypt(String(cfg.imageXorKey))
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
this.store.set('wxidConfigs', wxidConfigs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === 验证 ===
|
||||
|
||||
verifyAuthEnabled(): boolean {
|
||||
// 先检查 authEnabled 字段
|
||||
const rawEnabled: any = this.store.get('authEnabled')
|
||||
if (typeof rawEnabled === 'string' && rawEnabled.startsWith(SAFE_PREFIX)) {
|
||||
if (this.safeDecrypt(rawEnabled) === 'true') return true
|
||||
}
|
||||
|
||||
// 即使 authEnabled 被删除/篡改,如果密钥是 lock: 格式,说明曾开启过应用锁
|
||||
const rawDecryptKey: any = this.store.get('decryptKey')
|
||||
if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// === 工具方法 ===
|
||||
|
||||
getCacheBasePath(): string {
|
||||
const configured = this.get('cachePath')
|
||||
if (configured && configured.trim().length > 0) {
|
||||
return configured
|
||||
}
|
||||
return join(app.getPath('documents'), 'WeFlow')
|
||||
return join(app.getPath('userData'), 'cache')
|
||||
}
|
||||
|
||||
getAll(): ConfigSchema {
|
||||
getAll(): Partial<ConfigSchema> {
|
||||
return this.store.store
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.store.clear()
|
||||
this.unlockedKeys.clear()
|
||||
this.unlockPassword = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { chatService } from './chatService'
|
||||
import { videoService } from './videoService'
|
||||
import { voiceTranscribeService } from './voiceTranscribeService'
|
||||
import { EXPORT_HTML_STYLES } from './exportHtmlStyles'
|
||||
import { LRUCache } from '../utils/LRUCache.js'
|
||||
|
||||
// ChatLab 格式类型定义
|
||||
interface ChatLabHeader {
|
||||
@@ -140,12 +141,15 @@ async function parallelLimit<T, R>(
|
||||
|
||||
class ExportService {
|
||||
private configService: ConfigService
|
||||
private contactCache: Map<string, { displayName: string; avatarUrl?: string }> = new Map()
|
||||
private inlineEmojiCache: Map<string, string> = new Map()
|
||||
private contactCache: LRUCache<string, { displayName: string; avatarUrl?: string }>
|
||||
private inlineEmojiCache: LRUCache<string, string>
|
||||
private htmlStyleCache: string | null = null
|
||||
|
||||
constructor() {
|
||||
this.configService = new ConfigService()
|
||||
// 限制缓存大小,防止内存泄漏
|
||||
this.contactCache = new LRUCache(500) // 最多缓存500个联系人
|
||||
this.inlineEmojiCache = new LRUCache(100) // 最多缓存100个表情
|
||||
}
|
||||
|
||||
private getClampedConcurrency(value: number | undefined, fallback = 2, max = 6): number {
|
||||
@@ -219,9 +223,9 @@ class ExportService {
|
||||
*/
|
||||
async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
|
||||
try {
|
||||
const escapedChatroomId = chatroomId.replace(/'/g, "''")
|
||||
const sql = `SELECT ext_buffer FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1`
|
||||
const result = await wcdbService.execQuery('contact', null, sql)
|
||||
// 使用参数化查询防止SQL注入
|
||||
const sql = 'SELECT ext_buffer FROM chat_room WHERE username = ? LIMIT 1'
|
||||
const result = await wcdbService.execQuery('contact', null, sql, [chatroomId])
|
||||
if (!result.success || !result.rows || result.rows.length === 0) {
|
||||
return new Map<string, string>()
|
||||
}
|
||||
@@ -1467,6 +1471,7 @@ class ExportService {
|
||||
})
|
||||
|
||||
if (!result.success || !result.localPath) {
|
||||
console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`)
|
||||
// 尝试获取缩略图
|
||||
const thumbResult = await imageDecryptService.resolveCachedImage({
|
||||
sessionId,
|
||||
@@ -1474,8 +1479,10 @@ class ExportService {
|
||||
imageDatName
|
||||
})
|
||||
if (!thumbResult.success || !thumbResult.localPath) {
|
||||
console.log(`[Export] 缩略图也获取失败 (localId=${msg.localId}): error=${thumbResult.error || '未知'} → 将显示 [图片] 占位符`)
|
||||
return null
|
||||
}
|
||||
console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`)
|
||||
result.localPath = thumbResult.localPath
|
||||
}
|
||||
|
||||
@@ -1503,7 +1510,10 @@ class ExportService {
|
||||
}
|
||||
|
||||
// 复制文件
|
||||
if (!fs.existsSync(sourcePath)) return null
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
console.log(`[Export] 源图片文件不存在 (localId=${msg.localId}): ${sourcePath} → 将显示 [图片] 占位符`)
|
||||
return null
|
||||
}
|
||||
const ext = path.extname(sourcePath) || '.jpg'
|
||||
const fileName = `${messageId}_${imageKey}${ext}`
|
||||
const destPath = path.join(imagesDir, fileName)
|
||||
@@ -1517,6 +1527,7 @@ class ExportService {
|
||||
kind: 'image'
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Export] 导出图片异常 (localId=${msg.localId}):`, e, `→ 将显示 [图片] 占位符`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1785,7 +1796,14 @@ class ExportService {
|
||||
fileStream.close()
|
||||
resolve(true)
|
||||
})
|
||||
fileStream.on('error', () => {
|
||||
fileStream.on('error', (err) => {
|
||||
// 确保在错误情况下销毁流,释放文件句柄
|
||||
fileStream.destroy()
|
||||
resolve(false)
|
||||
})
|
||||
response.on('error', (err) => {
|
||||
// 确保在响应错误时也关闭文件句柄
|
||||
fileStream.destroy()
|
||||
resolve(false)
|
||||
})
|
||||
})
|
||||
@@ -1812,22 +1830,43 @@ class ExportService {
|
||||
let firstTime: number | null = null
|
||||
let lastTime: number | null = null
|
||||
|
||||
// 修复时间范围:0 表示不限制,而不是时间戳 0
|
||||
const beginTime = dateRange?.start || 0
|
||||
const endTime = dateRange?.end && dateRange.end > 0 ? dateRange.end : 0
|
||||
|
||||
console.log(`[Export] 收集消息: sessionId=${sessionId}, 时间范围: ${beginTime} ~ ${endTime || '无限制'}`)
|
||||
|
||||
const cursor = await wcdbService.openMessageCursor(
|
||||
sessionId,
|
||||
500,
|
||||
true,
|
||||
dateRange?.start || 0,
|
||||
dateRange?.end || 0
|
||||
beginTime,
|
||||
endTime
|
||||
)
|
||||
if (!cursor.success || !cursor.cursor) {
|
||||
console.error(`[Export] 打开游标失败: ${cursor.error || '未知错误'}`)
|
||||
return { rows, memberSet, firstTime, lastTime }
|
||||
}
|
||||
|
||||
try {
|
||||
let hasMore = true
|
||||
let batchCount = 0
|
||||
while (hasMore) {
|
||||
const batch = await wcdbService.fetchMessageBatch(cursor.cursor)
|
||||
if (!batch.success || !batch.rows) break
|
||||
batchCount++
|
||||
|
||||
if (!batch.success) {
|
||||
console.error(`[Export] 获取批次 ${batchCount} 失败: ${batch.error}`)
|
||||
break
|
||||
}
|
||||
|
||||
if (!batch.rows) {
|
||||
console.warn(`[Export] 批次 ${batchCount} 无数据`)
|
||||
break
|
||||
}
|
||||
|
||||
console.log(`[Export] 批次 ${batchCount}: 收到 ${batch.rows.length} 条消息`)
|
||||
|
||||
for (const row of batch.rows) {
|
||||
const createTime = parseInt(row.create_time || '0', 10)
|
||||
if (dateRange) {
|
||||
@@ -1918,8 +1957,17 @@ class ExportService {
|
||||
}
|
||||
hasMore = batch.hasMore === true
|
||||
}
|
||||
|
||||
console.log(`[Export] 收集完成: 共 ${rows.length} 条消息, ${batchCount} 个批次`)
|
||||
} catch (err) {
|
||||
console.error(`[Export] 收集消息异常:`, err)
|
||||
} finally {
|
||||
await wcdbService.closeMessageCursor(cursor.cursor)
|
||||
try {
|
||||
await wcdbService.closeMessageCursor(cursor.cursor)
|
||||
console.log(`[Export] 游标已关闭`)
|
||||
} catch (err) {
|
||||
console.error(`[Export] 关闭游标失败:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
if (senderSet.size > 0) {
|
||||
@@ -4562,6 +4610,12 @@ class ExportService {
|
||||
</html>`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.on('error', (err) => {
|
||||
// 确保在流错误时销毁流,释放文件句柄
|
||||
stream.destroy()
|
||||
reject(err)
|
||||
})
|
||||
|
||||
stream.end(() => {
|
||||
onProgress?.({
|
||||
current: 100,
|
||||
|
||||
@@ -100,6 +100,7 @@ class HttpService {
|
||||
private port: number = 5031
|
||||
private running: boolean = false
|
||||
private connections: Set<import('net').Socket> = new Set()
|
||||
private connectionMutex: boolean = false
|
||||
|
||||
constructor() {
|
||||
this.configService = ConfigService.getInstance()
|
||||
@@ -120,9 +121,20 @@ class HttpService {
|
||||
|
||||
// 跟踪所有连接,以便关闭时能强制断开
|
||||
this.server.on('connection', (socket) => {
|
||||
this.connections.add(socket)
|
||||
// 使用互斥锁防止并发修改
|
||||
if (!this.connectionMutex) {
|
||||
this.connectionMutex = true
|
||||
this.connections.add(socket)
|
||||
this.connectionMutex = false
|
||||
}
|
||||
|
||||
socket.on('close', () => {
|
||||
this.connections.delete(socket)
|
||||
// 使用互斥锁防止并发修改
|
||||
if (!this.connectionMutex) {
|
||||
this.connectionMutex = true
|
||||
this.connections.delete(socket)
|
||||
this.connectionMutex = false
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -150,11 +162,20 @@ class HttpService {
|
||||
async stop(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (this.server) {
|
||||
// 强制关闭所有活动连接
|
||||
for (const socket of this.connections) {
|
||||
socket.destroy()
|
||||
}
|
||||
// 使用互斥锁保护连接集合操作
|
||||
this.connectionMutex = true
|
||||
const socketsToClose = Array.from(this.connections)
|
||||
this.connections.clear()
|
||||
this.connectionMutex = false
|
||||
|
||||
// 强制关闭所有活动连接
|
||||
for (const socket of socketsToClose) {
|
||||
try {
|
||||
socket.destroy()
|
||||
} catch (err) {
|
||||
console.error('[HttpService] Error destroying socket:', err)
|
||||
}
|
||||
}
|
||||
|
||||
this.server.close(() => {
|
||||
this.running = false
|
||||
|
||||
@@ -45,6 +45,7 @@ type DecryptResult = {
|
||||
localPath?: string
|
||||
error?: string
|
||||
isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图)
|
||||
liveVideoPath?: string // 实况照片的视频路径
|
||||
}
|
||||
|
||||
type HardlinkState = {
|
||||
@@ -61,6 +62,7 @@ export class ImageDecryptService {
|
||||
private cacheIndexed = false
|
||||
private cacheIndexing: Promise<void> | null = null
|
||||
private updateFlags = new Map<string, boolean>()
|
||||
private noLiveSet = new Set<string>() // 已确认无 live 视频的图片路径
|
||||
|
||||
private logInfo(message: string, meta?: Record<string, unknown>): void {
|
||||
if (!this.configService.get('logEnabled')) return
|
||||
@@ -116,8 +118,9 @@ export class ImageDecryptService {
|
||||
} else {
|
||||
this.updateFlags.delete(key)
|
||||
}
|
||||
const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(cached)
|
||||
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(cached))
|
||||
return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate }
|
||||
return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate, liveVideoPath }
|
||||
}
|
||||
if (cached && !this.isImageFile(cached)) {
|
||||
this.resolvedCache.delete(key)
|
||||
@@ -136,8 +139,9 @@ export class ImageDecryptService {
|
||||
} else {
|
||||
this.updateFlags.delete(key)
|
||||
}
|
||||
const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(existing)
|
||||
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(existing))
|
||||
return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate }
|
||||
return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate, liveVideoPath }
|
||||
}
|
||||
}
|
||||
this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
@@ -151,13 +155,25 @@ export class ImageDecryptService {
|
||||
return { success: false, error: '缺少图片标识' }
|
||||
}
|
||||
|
||||
if (payload.force) {
|
||||
const hdCached = this.findCachedOutput(cacheKey, true, payload.sessionId)
|
||||
if (hdCached && existsSync(hdCached) && this.isImageFile(hdCached) && !this.isThumbnailPath(hdCached)) {
|
||||
const dataUrl = this.fileToDataUrl(hdCached)
|
||||
const localPath = dataUrl || this.filePathToUrl(hdCached)
|
||||
const liveVideoPath = this.checkLiveVideoCache(hdCached)
|
||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
||||
return { success: true, localPath, isThumb: false, liveVideoPath }
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload.force) {
|
||||
const cached = this.resolvedCache.get(cacheKey)
|
||||
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
||||
const dataUrl = this.fileToDataUrl(cached)
|
||||
const localPath = dataUrl || this.filePathToUrl(cached)
|
||||
const liveVideoPath = this.isThumbnailPath(cached) ? undefined : this.checkLiveVideoCache(cached)
|
||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
||||
return { success: true, localPath }
|
||||
return { success: true, localPath, liveVideoPath }
|
||||
}
|
||||
if (cached && !this.isImageFile(cached)) {
|
||||
this.resolvedCache.delete(cacheKey)
|
||||
@@ -235,8 +251,9 @@ export class ImageDecryptService {
|
||||
const dataUrl = this.fileToDataUrl(existing)
|
||||
const localPath = dataUrl || this.filePathToUrl(existing)
|
||||
const isThumb = this.isThumbnailPath(existing)
|
||||
const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(existing)
|
||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
||||
return { success: true, localPath, isThumb }
|
||||
return { success: true, localPath, isThumb, liveVideoPath }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,7 +313,15 @@ export class ImageDecryptService {
|
||||
const dataUrl = this.bufferToDataUrl(decrypted, finalExt)
|
||||
const localPath = dataUrl || this.filePathToUrl(outputPath)
|
||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
||||
return { success: true, localPath, isThumb }
|
||||
|
||||
// 检测实况照片(Motion Photo)
|
||||
let liveVideoPath: string | undefined
|
||||
if (!isThumb && (finalExt === '.jpg' || finalExt === '.jpeg')) {
|
||||
const videoPath = await this.extractMotionPhotoVideo(outputPath, decrypted)
|
||||
if (videoPath) liveVideoPath = this.filePathToUrl(videoPath)
|
||||
}
|
||||
|
||||
return { success: true, localPath, isThumb, liveVideoPath }
|
||||
} catch (e) {
|
||||
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
return { success: false, error: String(e) }
|
||||
@@ -332,23 +357,37 @@ export class ImageDecryptService {
|
||||
* 获取解密后的缓存目录(用于查找 hardlink.db)
|
||||
*/
|
||||
private getDecryptedCacheDir(wxid: string): string | null {
|
||||
const cachePath = this.configService.get('cachePath')
|
||||
if (!cachePath) return null
|
||||
|
||||
const cleanedWxid = this.cleanAccountDirName(wxid)
|
||||
const cacheAccountDir = join(cachePath, cleanedWxid)
|
||||
const configured = this.configService.get('cachePath')
|
||||
const documentsPath = app.getPath('documents')
|
||||
const baseCandidates = Array.from(new Set([
|
||||
configured || '',
|
||||
join(documentsPath, 'WeFlow'),
|
||||
join(documentsPath, 'WeFlowData'),
|
||||
this.configService.getCacheBasePath()
|
||||
].filter(Boolean)))
|
||||
|
||||
// 检查缓存目录下是否有 hardlink.db
|
||||
if (existsSync(join(cacheAccountDir, 'hardlink.db'))) {
|
||||
return cacheAccountDir
|
||||
}
|
||||
if (existsSync(join(cachePath, 'hardlink.db'))) {
|
||||
return cachePath
|
||||
}
|
||||
const cacheHardlinkDir = join(cacheAccountDir, 'db_storage', 'hardlink')
|
||||
if (existsSync(join(cacheHardlinkDir, 'hardlink.db'))) {
|
||||
return cacheHardlinkDir
|
||||
for (const base of baseCandidates) {
|
||||
const accountCandidates = Array.from(new Set([
|
||||
join(base, wxid),
|
||||
join(base, cleanedWxid),
|
||||
join(base, 'databases', wxid),
|
||||
join(base, 'databases', cleanedWxid)
|
||||
]))
|
||||
for (const accountDir of accountCandidates) {
|
||||
if (existsSync(join(accountDir, 'hardlink.db'))) {
|
||||
return accountDir
|
||||
}
|
||||
const hardlinkSubdir = join(accountDir, 'db_storage', 'hardlink')
|
||||
if (existsSync(join(hardlinkSubdir, 'hardlink.db'))) {
|
||||
return hardlinkSubdir
|
||||
}
|
||||
}
|
||||
if (existsSync(join(base, 'hardlink.db'))) {
|
||||
return base
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -357,7 +396,8 @@ export class ImageDecryptService {
|
||||
existsSync(join(dirPath, 'hardlink.db')) ||
|
||||
existsSync(join(dirPath, 'db_storage')) ||
|
||||
existsSync(join(dirPath, 'FileStorage', 'Image')) ||
|
||||
existsSync(join(dirPath, 'FileStorage', 'Image2'))
|
||||
existsSync(join(dirPath, 'FileStorage', 'Image2')) ||
|
||||
existsSync(join(dirPath, 'msg', 'attach'))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -423,6 +463,12 @@ export class ImageDecryptService {
|
||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||
return hdPath
|
||||
}
|
||||
const hdInDir = await this.searchDatFileInDir(dirname(hardlinkPath), imageDatName || imageMd5 || '', false)
|
||||
if (hdInDir) {
|
||||
this.cacheDatPath(accountDir, imageMd5, hdInDir)
|
||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdInDir)
|
||||
return hdInDir
|
||||
}
|
||||
// 没找到高清图,返回 null(不进行全局搜索)
|
||||
return null
|
||||
}
|
||||
@@ -440,9 +486,16 @@ export class ImageDecryptService {
|
||||
// 找到缩略图但要求高清图,尝试同目录查找高清图变体
|
||||
const hdPath = this.findHdVariantInSameDir(fallbackPath)
|
||||
if (hdPath) {
|
||||
this.cacheDatPath(accountDir, imageMd5, hdPath)
|
||||
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||
return hdPath
|
||||
}
|
||||
const hdInDir = await this.searchDatFileInDir(dirname(fallbackPath), imageDatName || imageMd5 || '', false)
|
||||
if (hdInDir) {
|
||||
this.cacheDatPath(accountDir, imageMd5, hdInDir)
|
||||
this.cacheDatPath(accountDir, imageDatName, hdInDir)
|
||||
return hdInDir
|
||||
}
|
||||
return null
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
|
||||
@@ -465,15 +518,17 @@ export class ImageDecryptService {
|
||||
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||
return hdPath
|
||||
}
|
||||
const hdInDir = await this.searchDatFileInDir(dirname(hardlinkPath), imageDatName || '', false)
|
||||
if (hdInDir) {
|
||||
this.cacheDatPath(accountDir, imageDatName, hdInDir)
|
||||
return hdInDir
|
||||
}
|
||||
return null
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
|
||||
}
|
||||
|
||||
// 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢)
|
||||
if (!allowThumbnail) {
|
||||
return null
|
||||
}
|
||||
// force 模式下也继续尝试缓存目录/文件系统搜索,避免 hardlink.db 缺行时只能拿到缩略图
|
||||
|
||||
if (!imageDatName) return null
|
||||
if (!skipResolvedCache) {
|
||||
@@ -483,6 +538,8 @@ export class ImageDecryptService {
|
||||
// 缓存的是缩略图,尝试找高清图
|
||||
const hdPath = this.findHdVariantInSameDir(cached)
|
||||
if (hdPath) return hdPath
|
||||
const hdInDir = await this.searchDatFileInDir(dirname(cached), imageDatName, false)
|
||||
if (hdInDir) return hdInDir
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1681,6 +1738,76 @@ export class ImageDecryptService {
|
||||
return mostCommonKey
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查图片对应的 live 视频缓存,返回 file:// URL 或 undefined
|
||||
* 已确认无 live 的路径会被记录,下次直接跳过
|
||||
*/
|
||||
private checkLiveVideoCache(imagePath: string): string | undefined {
|
||||
if (this.noLiveSet.has(imagePath)) return undefined
|
||||
const livePath = imagePath.replace(/\.(jpg|jpeg|png)$/i, '_live.mp4')
|
||||
if (existsSync(livePath)) return this.filePathToUrl(livePath)
|
||||
this.noLiveSet.add(imagePath)
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测并分离 Motion Photo(实况照片)
|
||||
* Google Motion Photo = JPEG + MP4 拼接在一起
|
||||
* 返回视频文件路径,如果不是实况照片则返回 null
|
||||
*/
|
||||
private async extractMotionPhotoVideo(imagePath: string, imageBuffer: Buffer): Promise<string | null> {
|
||||
// 只处理 JPEG 文件
|
||||
if (imageBuffer.length < 8) return null
|
||||
if (imageBuffer[0] !== 0xff || imageBuffer[1] !== 0xd8) return null
|
||||
|
||||
// 从末尾向前搜索 MP4 ftyp 原子签名
|
||||
// ftyp 原子结构: [4字节大小][ftyp(66 74 79 70)][品牌...]
|
||||
// 实际起始位置在 ftyp 前4字节(大小字段)
|
||||
const ftypSig = [0x66, 0x74, 0x79, 0x70] // 'ftyp'
|
||||
let videoOffset: number | null = null
|
||||
|
||||
const searchEnd = Math.max(0, imageBuffer.length - 8)
|
||||
for (let i = searchEnd; i > 0; i--) {
|
||||
if (imageBuffer[i] === ftypSig[0] &&
|
||||
imageBuffer[i + 1] === ftypSig[1] &&
|
||||
imageBuffer[i + 2] === ftypSig[2] &&
|
||||
imageBuffer[i + 3] === ftypSig[3]) {
|
||||
// ftyp 前4字节是 box size,实际 MP4 从这里开始
|
||||
videoOffset = i - 4
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 备用:从 XMP 元数据中读取偏移量
|
||||
if (videoOffset === null || videoOffset <= 0) {
|
||||
try {
|
||||
const text = imageBuffer.toString('latin1')
|
||||
const match = text.match(/MediaDataOffset="(\d+)"/i) || text.match(/MicroVideoOffset="(\d+)"/i)
|
||||
if (match) {
|
||||
const offset = parseInt(match[1], 10)
|
||||
if (offset > 0 && offset < imageBuffer.length) {
|
||||
videoOffset = imageBuffer.length - offset
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
if (videoOffset === null || videoOffset <= 100) return null
|
||||
|
||||
// 验证视频部分确实以有效 MP4 数据开头
|
||||
const videoStart = imageBuffer[videoOffset + 4] === 0x66 &&
|
||||
imageBuffer[videoOffset + 5] === 0x74 &&
|
||||
imageBuffer[videoOffset + 6] === 0x79 &&
|
||||
imageBuffer[videoOffset + 7] === 0x70
|
||||
if (!videoStart) return null
|
||||
|
||||
// 写出视频文件
|
||||
const videoPath = imagePath.replace(/\.(jpg|jpeg|png)$/i, '_live.mp4')
|
||||
const videoBuffer = imageBuffer.slice(videoOffset)
|
||||
await writeFile(videoPath, videoBuffer)
|
||||
return videoPath
|
||||
}
|
||||
|
||||
/**
|
||||
* 解包 wxgf 格式
|
||||
* wxgf 是微信的图片格式,内部使用 HEVC 编码
|
||||
|
||||
@@ -843,7 +843,7 @@ export class KeyService {
|
||||
private findTemplateDatFiles(rootDir: string): string[] {
|
||||
const files: string[] = []
|
||||
const stack = [rootDir]
|
||||
const maxFiles = 32
|
||||
const maxFiles = 256
|
||||
while (stack.length && files.length < maxFiles) {
|
||||
const dir = stack.pop() as string
|
||||
let entries: string[]
|
||||
@@ -877,7 +877,7 @@ export class KeyService {
|
||||
if (ma && mb) return mb.localeCompare(ma)
|
||||
return 0
|
||||
})
|
||||
return files.slice(0, 16)
|
||||
return files.slice(0, 128)
|
||||
}
|
||||
|
||||
private getXorKey(templateFiles: string[]): number | null {
|
||||
|
||||
@@ -1,371 +0,0 @@
|
||||
import fs from "fs";
|
||||
import { app, BrowserWindow } from "electron";
|
||||
import path from "path";
|
||||
import { ConfigService } from './config';
|
||||
|
||||
// Define interfaces locally to avoid static import of types that might not be available or cause issues
|
||||
type LlamaModel = any;
|
||||
type LlamaContext = any;
|
||||
type LlamaChatSession = any;
|
||||
|
||||
export class LlamaService {
|
||||
private _model: LlamaModel | null = null;
|
||||
private _context: LlamaContext | null = null;
|
||||
private _sequence: any = null;
|
||||
private _session: LlamaChatSession | null = null;
|
||||
private _llama: any = null;
|
||||
private _nodeLlamaCpp: any = null;
|
||||
private configService = new ConfigService();
|
||||
private _initialized = false;
|
||||
|
||||
constructor() {
|
||||
// 延迟初始化,只在需要时初始化
|
||||
}
|
||||
|
||||
public async init() {
|
||||
if (this._initialized) return;
|
||||
|
||||
try {
|
||||
// Dynamic import to handle ESM module in CJS context
|
||||
this._nodeLlamaCpp = await import("node-llama-cpp");
|
||||
this._llama = await this._nodeLlamaCpp.getLlama();
|
||||
this._initialized = true;
|
||||
console.log("[LlamaService] Llama initialized");
|
||||
} catch (error) {
|
||||
console.error("[LlamaService] Failed to initialize Llama:", error);
|
||||
}
|
||||
}
|
||||
|
||||
public async loadModel(modelPath: string) {
|
||||
if (!this._llama) await this.init();
|
||||
|
||||
try {
|
||||
console.log("[LlamaService] Loading model from:", modelPath);
|
||||
if (!this._llama) {
|
||||
throw new Error("Llama not initialized");
|
||||
}
|
||||
this._model = await this._llama.loadModel({
|
||||
modelPath: modelPath,
|
||||
gpuLayers: 'max', // Offload all layers to GPU if possible
|
||||
useMlock: false // Disable mlock to avoid "VirtualLock" errors (common on Windows)
|
||||
});
|
||||
|
||||
if (!this._model) throw new Error("Failed to load model");
|
||||
|
||||
this._context = await this._model.createContext({
|
||||
contextSize: 8192, // Balanced context size for better performance
|
||||
batchSize: 2048 // Increase batch size for better prompt processing speed
|
||||
});
|
||||
|
||||
if (!this._context) throw new Error("Failed to create context");
|
||||
|
||||
this._sequence = this._context.getSequence();
|
||||
|
||||
const { LlamaChatSession } = this._nodeLlamaCpp;
|
||||
this._session = new LlamaChatSession({
|
||||
contextSequence: this._sequence
|
||||
});
|
||||
|
||||
console.log("[LlamaService] Model loaded successfully");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("[LlamaService] Failed to load model:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async createSession(systemPrompt?: string) {
|
||||
if (!this._context) throw new Error("Model not loaded");
|
||||
if (!this._nodeLlamaCpp) await this.init();
|
||||
|
||||
const { LlamaChatSession } = this._nodeLlamaCpp;
|
||||
|
||||
if (!this._sequence) {
|
||||
this._sequence = this._context.getSequence();
|
||||
}
|
||||
|
||||
this._session = new LlamaChatSession({
|
||||
contextSequence: this._sequence,
|
||||
systemPrompt: systemPrompt
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async chat(message: string, options: { thinking?: boolean } = {}, onToken: (token: string) => void) {
|
||||
if (!this._session) throw new Error("Session not initialized");
|
||||
|
||||
const thinking = options.thinking ?? false;
|
||||
|
||||
// Sampling parameters based on mode
|
||||
const samplingParams = thinking ? {
|
||||
temperature: 0.6,
|
||||
topP: 0.95,
|
||||
topK: 20,
|
||||
repeatPenalty: 1.5 // PresencePenalty=1.5
|
||||
} : {
|
||||
temperature: 0.7,
|
||||
topP: 0.8,
|
||||
topK: 20,
|
||||
repeatPenalty: 1.5
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await this._session.prompt(message, {
|
||||
...samplingParams,
|
||||
onTextChunk: (chunk: string) => {
|
||||
onToken(chunk);
|
||||
}
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("[LlamaService] Chat error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async getModelStatus(modelPath: string) {
|
||||
try {
|
||||
const exists = fs.existsSync(modelPath);
|
||||
if (!exists) {
|
||||
return { exists: false, path: modelPath };
|
||||
}
|
||||
const stats = fs.statSync(modelPath);
|
||||
return {
|
||||
exists: true,
|
||||
path: modelPath,
|
||||
size: stats.size
|
||||
};
|
||||
} catch (error) {
|
||||
return { exists: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
private resolveModelDir(): string {
|
||||
const configured = this.configService.get('whisperModelDir') as string | undefined;
|
||||
if (configured) return configured;
|
||||
return path.join(app.getPath('documents'), 'WeFlow', 'models');
|
||||
}
|
||||
|
||||
public async downloadModel(url: string, savePath: string, onProgress: (payload: { downloaded: number; total: number; speed: number }) => void): Promise<void> {
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(savePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
console.info(`[LlamaService] Multi-threaded download check for: ${savePath}`);
|
||||
|
||||
if (fs.existsSync(savePath)) {
|
||||
fs.unlinkSync(savePath);
|
||||
}
|
||||
|
||||
// 1. Get total size and check range support
|
||||
let probeResult;
|
||||
try {
|
||||
probeResult = await this.probeUrl(url);
|
||||
} catch (err) {
|
||||
console.warn("[LlamaService] Probe failed, falling back to single-thread.", err);
|
||||
return this.downloadSingleThread(url, savePath, onProgress);
|
||||
}
|
||||
|
||||
const { totalSize, acceptRanges, finalUrl } = probeResult;
|
||||
console.log(`[LlamaService] Total size: ${totalSize}, Accept-Ranges: ${acceptRanges}`);
|
||||
|
||||
if (totalSize <= 0 || !acceptRanges) {
|
||||
console.warn("[LlamaService] Ranges not supported or size unknown, falling back to single-thread.");
|
||||
return this.downloadSingleThread(finalUrl, savePath, onProgress);
|
||||
}
|
||||
|
||||
const threadCount = 4;
|
||||
const chunkSize = Math.ceil(totalSize / threadCount);
|
||||
const fd = fs.openSync(savePath, 'w');
|
||||
|
||||
let downloadedLength = 0;
|
||||
let lastDownloadedLength = 0;
|
||||
let lastTime = Date.now();
|
||||
let speed = 0;
|
||||
|
||||
const speedInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const duration = (now - lastTime) / 1000;
|
||||
if (duration > 0) {
|
||||
speed = (downloadedLength - lastDownloadedLength) / duration;
|
||||
lastDownloadedLength = downloadedLength;
|
||||
lastTime = now;
|
||||
onProgress({ downloaded: downloadedLength, total: totalSize, speed });
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
try {
|
||||
const promises = [];
|
||||
for (let i = 0; i < threadCount; i++) {
|
||||
const start = i * chunkSize;
|
||||
const end = i === threadCount - 1 ? totalSize - 1 : (i + 1) * chunkSize - 1;
|
||||
|
||||
promises.push(this.downloadChunk(finalUrl, fd, start, end, (bytes) => {
|
||||
downloadedLength += bytes;
|
||||
}));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
console.log("[LlamaService] Multi-threaded download complete");
|
||||
|
||||
// Final progress update
|
||||
onProgress({ downloaded: totalSize, total: totalSize, speed: 0 });
|
||||
} catch (err) {
|
||||
console.error("[LlamaService] Multi-threaded download failed:", err);
|
||||
throw err;
|
||||
} finally {
|
||||
clearInterval(speedInterval);
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
private async probeUrl(url: string): Promise<{ totalSize: number, acceptRanges: boolean, finalUrl: string }> {
|
||||
const protocol = url.startsWith('https') ? require('https') : require('http');
|
||||
const options = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Referer': 'https://www.modelscope.cn/',
|
||||
'Range': 'bytes=0-0'
|
||||
}
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = protocol.get(url, options, (res: any) => {
|
||||
if ([301, 302, 307, 308].includes(res.statusCode)) {
|
||||
const location = res.headers.location;
|
||||
const nextUrl = new URL(location, url).href;
|
||||
this.probeUrl(nextUrl).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.statusCode !== 206 && res.statusCode !== 200) {
|
||||
reject(new Error(`Probe failed: HTTP ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const contentRange = res.headers['content-range'];
|
||||
let totalSize = 0;
|
||||
if (contentRange) {
|
||||
const parts = contentRange.split('/');
|
||||
totalSize = parseInt(parts[parts.length - 1], 10);
|
||||
} else {
|
||||
totalSize = parseInt(res.headers['content-length'] || '0', 10);
|
||||
}
|
||||
|
||||
const acceptRanges = res.headers['accept-ranges'] === 'bytes' || !!contentRange;
|
||||
resolve({ totalSize, acceptRanges, finalUrl: url });
|
||||
res.destroy();
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
private async downloadChunk(url: string, fd: number, start: number, end: number, onData: (bytes: number) => void): Promise<void> {
|
||||
const protocol = url.startsWith('https') ? require('https') : require('http');
|
||||
const options = {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Referer': 'https://www.modelscope.cn/',
|
||||
'Range': `bytes=${start}-${end}`
|
||||
}
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = protocol.get(url, options, (res: any) => {
|
||||
if (res.statusCode !== 206) {
|
||||
reject(new Error(`Chunk download failed: HTTP ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
let currentOffset = start;
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
try {
|
||||
fs.writeSync(fd, chunk, 0, chunk.length, currentOffset);
|
||||
currentOffset += chunk.length;
|
||||
onData(chunk.length);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
res.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
res.on('end', () => resolve());
|
||||
res.on('error', reject);
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
private async downloadSingleThread(url: string, savePath: string, onProgress: (payload: { downloaded: number; total: number; speed: number }) => void): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const protocol = url.startsWith('https') ? require('https') : require('http');
|
||||
const options = {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Referer': 'https://www.modelscope.cn/'
|
||||
}
|
||||
};
|
||||
|
||||
const request = protocol.get(url, options, (response: any) => {
|
||||
if ([301, 302, 307, 308].includes(response.statusCode)) {
|
||||
const location = response.headers.location;
|
||||
const nextUrl = new URL(location, url).href;
|
||||
this.downloadSingleThread(nextUrl, savePath, onProgress).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`Fallback download failed: HTTP ${response.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const totalLength = parseInt(response.headers['content-length'] || '0', 10);
|
||||
let downloadedLength = 0;
|
||||
let lastDownloadedLength = 0;
|
||||
let lastTime = Date.now();
|
||||
let speed = 0;
|
||||
|
||||
const fileStream = fs.createWriteStream(savePath);
|
||||
response.pipe(fileStream);
|
||||
|
||||
const speedInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const duration = (now - lastTime) / 1000;
|
||||
if (duration > 0) {
|
||||
speed = (downloadedLength - lastDownloadedLength) / duration;
|
||||
lastDownloadedLength = downloadedLength;
|
||||
lastTime = now;
|
||||
onProgress({ downloaded: downloadedLength, total: totalLength, speed });
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
response.on('data', (chunk: any) => {
|
||||
downloadedLength += chunk.length;
|
||||
});
|
||||
|
||||
fileStream.on('finish', () => {
|
||||
clearInterval(speedInterval);
|
||||
fileStream.close();
|
||||
resolve();
|
||||
});
|
||||
|
||||
fileStream.on('error', (err: any) => {
|
||||
clearInterval(speedInterval);
|
||||
fs.unlink(savePath, () => { });
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
request.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
public getModelsPath() {
|
||||
return this.resolveModelDir();
|
||||
}
|
||||
}
|
||||
|
||||
export const llamaService = new LlamaService();
|
||||
@@ -147,6 +147,18 @@ class SnsService {
|
||||
return join(this.getSnsCacheDir(), `${hash}${ext}`)
|
||||
}
|
||||
|
||||
// 获取所有发过朋友圈的用户名列表
|
||||
async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
|
||||
const result = await wcdbService.execQuery('sns', null, 'SELECT DISTINCT user_name FROM SnsTimeLine')
|
||||
if (!result.success || !result.rows) {
|
||||
// 尝试 userName 列名
|
||||
const result2 = await wcdbService.execQuery('sns', null, 'SELECT DISTINCT userName FROM SnsTimeLine')
|
||||
if (!result2.success || !result2.rows) return { success: false, error: result.error || result2.error }
|
||||
return { success: true, usernames: result2.rows.map((r: any) => r.userName).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)
|
||||
|
||||
|
||||
@@ -458,8 +458,18 @@ export class VoiceTranscribeService {
|
||||
|
||||
writer.on('error', (err) => {
|
||||
clearInterval(speedInterval)
|
||||
// 确保在错误情况下也关闭文件句柄
|
||||
writer.destroy()
|
||||
reject(err)
|
||||
})
|
||||
|
||||
response.on('error', (err) => {
|
||||
clearInterval(speedInterval)
|
||||
// 确保在响应错误时也关闭文件句柄
|
||||
writer.destroy()
|
||||
reject(err)
|
||||
})
|
||||
|
||||
response.pipe(writer)
|
||||
})
|
||||
request.on('error', reject)
|
||||
|
||||
@@ -66,8 +66,12 @@ export class WcdbCore {
|
||||
private wcdbVerifyUser: any = null
|
||||
private wcdbStartMonitorPipe: any = null
|
||||
private wcdbStopMonitorPipe: any = null
|
||||
private wcdbGetMonitorPipeName: any = null
|
||||
|
||||
private monitorPipeClient: any = null
|
||||
private monitorCallback: ((type: string, json: string) => void) | null = null
|
||||
private monitorReconnectTimer: any = null
|
||||
private monitorPipePath: string = ''
|
||||
|
||||
|
||||
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
|
||||
@@ -92,63 +96,94 @@ export class WcdbCore {
|
||||
// 使用命名管道 IPC
|
||||
startMonitor(callback: (type: string, json: string) => void): boolean {
|
||||
if (!this.wcdbStartMonitorPipe) {
|
||||
this.writeLog('startMonitor: wcdbStartMonitorPipe not available')
|
||||
return false
|
||||
}
|
||||
|
||||
this.monitorCallback = callback
|
||||
|
||||
try {
|
||||
const result = this.wcdbStartMonitorPipe()
|
||||
if (result !== 0) {
|
||||
this.writeLog(`startMonitor: wcdbStartMonitorPipe failed with ${result}`)
|
||||
return false
|
||||
}
|
||||
|
||||
const net = require('net')
|
||||
const PIPE_PATH = '\\\\.\\pipe\\weflow_monitor'
|
||||
|
||||
setTimeout(() => {
|
||||
this.monitorPipeClient = net.createConnection(PIPE_PATH, () => {
|
||||
this.writeLog('Monitor pipe connected')
|
||||
})
|
||||
|
||||
let buffer = ''
|
||||
this.monitorPipeClient.on('data', (data: Buffer) => {
|
||||
buffer += data.toString('utf8')
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(line)
|
||||
callback(parsed.action || 'update', line)
|
||||
} catch {
|
||||
callback('update', line)
|
||||
}
|
||||
}
|
||||
// 从 DLL 获取动态管道名(含 PID)
|
||||
let pipePath = '\\\\.\\pipe\\weflow_monitor'
|
||||
if (this.wcdbGetMonitorPipeName) {
|
||||
try {
|
||||
const namePtr = [null as any]
|
||||
if (this.wcdbGetMonitorPipeName(namePtr) === 0 && namePtr[0]) {
|
||||
pipePath = this.koffi.decode(namePtr[0], 'char', -1)
|
||||
this.wcdbFreeString(namePtr[0])
|
||||
}
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
|
||||
this.monitorPipeClient.on('error', (err: Error) => {
|
||||
this.writeLog(`Monitor pipe error: ${err.message}`)
|
||||
})
|
||||
|
||||
this.monitorPipeClient.on('close', () => {
|
||||
this.writeLog('Monitor pipe closed')
|
||||
this.monitorPipeClient = null
|
||||
})
|
||||
}, 100)
|
||||
|
||||
this.writeLog('Monitor started via named pipe IPC')
|
||||
this.connectMonitorPipe(pipePath)
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('打开数据库异常:', e)
|
||||
console.error('[wcdbCore] startMonitor exception:', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 连接命名管道,支持断开后自动重连
|
||||
private connectMonitorPipe(pipePath: string) {
|
||||
this.monitorPipePath = pipePath
|
||||
const net = require('net')
|
||||
|
||||
setTimeout(() => {
|
||||
if (!this.monitorCallback) return
|
||||
|
||||
this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => {
|
||||
})
|
||||
|
||||
let buffer = ''
|
||||
this.monitorPipeClient.on('data', (data: Buffer) => {
|
||||
buffer += data.toString('utf8')
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(line)
|
||||
this.monitorCallback?.(parsed.action || 'update', line)
|
||||
} catch {
|
||||
this.monitorCallback?.('update', line)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.monitorPipeClient.on('error', () => {
|
||||
})
|
||||
|
||||
this.monitorPipeClient.on('close', () => {
|
||||
this.monitorPipeClient = null
|
||||
this.scheduleReconnect()
|
||||
})
|
||||
}, 100)
|
||||
}
|
||||
|
||||
// 定时重连
|
||||
private scheduleReconnect() {
|
||||
if (this.monitorReconnectTimer || !this.monitorCallback) return
|
||||
this.monitorReconnectTimer = setTimeout(() => {
|
||||
this.monitorReconnectTimer = null
|
||||
if (this.monitorCallback && !this.monitorPipeClient) {
|
||||
this.connectMonitorPipe(this.monitorPipePath)
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
|
||||
|
||||
stopMonitor(): void {
|
||||
this.monitorCallback = null
|
||||
if (this.monitorReconnectTimer) {
|
||||
clearTimeout(this.monitorReconnectTimer)
|
||||
this.monitorReconnectTimer = null
|
||||
}
|
||||
if (this.monitorPipeClient) {
|
||||
this.monitorPipeClient.destroy()
|
||||
this.monitorPipeClient = null
|
||||
@@ -569,11 +604,13 @@ export class WcdbCore {
|
||||
try {
|
||||
this.wcdbStartMonitorPipe = this.lib.func('int32 wcdb_start_monitor_pipe()')
|
||||
this.wcdbStopMonitorPipe = this.lib.func('void wcdb_stop_monitor_pipe()')
|
||||
this.wcdbGetMonitorPipeName = this.lib.func('int32 wcdb_get_monitor_pipe_name(_Out_ void** outName)')
|
||||
this.writeLog('Monitor pipe functions loaded')
|
||||
} catch (e) {
|
||||
console.warn('Failed to load monitor pipe functions:', e)
|
||||
this.wcdbStartMonitorPipe = null
|
||||
this.wcdbStopMonitorPipe = null
|
||||
this.wcdbGetMonitorPipeName = null
|
||||
}
|
||||
|
||||
// void VerifyUser(int64_t hwnd_ptr, const char* message, char* out_result, int max_len)
|
||||
@@ -987,7 +1024,7 @@ export class WcdbCore {
|
||||
}
|
||||
try {
|
||||
// 1. 打开游标 (使用 Ascending=1 从指定时间往后查)
|
||||
const openRes = await this.openMessageCursorLite(sessionId, limit, true, minTime, 0)
|
||||
const openRes = await this.openMessageCursor(sessionId, limit, true, minTime, 0)
|
||||
if (!openRes.success || !openRes.cursor) {
|
||||
return { success: false, error: openRes.error }
|
||||
}
|
||||
@@ -1583,12 +1620,20 @@ export class WcdbCore {
|
||||
}
|
||||
}
|
||||
|
||||
async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
||||
async execQuery(kind: string, path: string | null, sql: string, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
||||
if (!this.ensureReady()) {
|
||||
return { success: false, error: 'WCDB 未连接' }
|
||||
}
|
||||
try {
|
||||
if (!this.wcdbExecQuery) return { success: false, error: '接口未就绪' }
|
||||
|
||||
// 如果提供了参数,使用参数化查询(需要 C++ 层支持)
|
||||
// 注意:当前 wcdbExecQuery 可能不支持参数化,这是一个占位符实现
|
||||
// TODO: 需要更新 C++ 层的 wcdb_exec_query 以支持参数绑定
|
||||
if (params && params.length > 0) {
|
||||
console.warn('[wcdbCore] execQuery: 参数化查询暂未在 C++ 层实现,将使用原始 SQL(可能存在注入风险)')
|
||||
}
|
||||
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbExecQuery(this.handle, kind, path || '', sql, outPtr)
|
||||
if (result !== 0 || !outPtr[0]) {
|
||||
|
||||
@@ -136,7 +136,6 @@ export class WcdbService {
|
||||
*/
|
||||
setMonitor(callback: (type: string, json: string) => void): void {
|
||||
this.monitorListener = callback;
|
||||
// Notify worker to enable monitor
|
||||
this.callWorker('setMonitor').catch(() => { });
|
||||
}
|
||||
|
||||
@@ -362,10 +361,10 @@ export class WcdbService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 SQL 查询
|
||||
* 执行 SQL 查询(支持参数化查询)
|
||||
*/
|
||||
async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
||||
return this.callWorker('execQuery', { kind, path, sql })
|
||||
async execQuery(kind: string, path: string | null, sql: string, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
||||
return this.callWorker('execQuery', { kind, path, sql, params })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
114
electron/utils/LRUCache.ts
Normal file
114
electron/utils/LRUCache.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* LRU (Least Recently Used) Cache implementation for memory management
|
||||
*/
|
||||
export class LRUCache<K, V> {
|
||||
private cache: Map<K, V>
|
||||
private maxSize: number
|
||||
|
||||
constructor(maxSize: number = 100) {
|
||||
this.maxSize = maxSize
|
||||
this.cache = new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get value from cache
|
||||
*/
|
||||
get(key: K): V | undefined {
|
||||
const value = this.cache.get(key)
|
||||
if (value !== undefined) {
|
||||
// Move to end (most recently used)
|
||||
this.cache.delete(key)
|
||||
this.cache.set(key, value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Set value in cache
|
||||
*/
|
||||
set(key: K, value: V): void {
|
||||
if (this.cache.has(key)) {
|
||||
// Update existing
|
||||
this.cache.delete(key)
|
||||
} else if (this.cache.size >= this.maxSize) {
|
||||
// Remove least recently used (first item)
|
||||
const firstKey = this.cache.keys().next().value
|
||||
if (firstKey !== undefined) {
|
||||
this.cache.delete(firstKey)
|
||||
}
|
||||
}
|
||||
this.cache.set(key, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if key exists
|
||||
*/
|
||||
has(key: K): boolean {
|
||||
return this.cache.has(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete key from cache
|
||||
*/
|
||||
delete(key: K): boolean {
|
||||
return this.cache.delete(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cache entries
|
||||
*/
|
||||
clear(): void {
|
||||
this.cache.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current cache size
|
||||
*/
|
||||
get size(): number {
|
||||
return this.cache.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all keys (for debugging)
|
||||
*/
|
||||
keys(): IterableIterator<K> {
|
||||
return this.cache.keys()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all values (for debugging)
|
||||
*/
|
||||
values(): IterableIterator<V> {
|
||||
return this.cache.values()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all entries (for iteration support)
|
||||
*/
|
||||
entries(): IterableIterator<[K, V]> {
|
||||
return this.cache.entries()
|
||||
}
|
||||
|
||||
/**
|
||||
* Make LRUCache iterable (for...of support)
|
||||
*/
|
||||
[Symbol.iterator](): IterableIterator<[K, V]> {
|
||||
return this.cache.entries()
|
||||
}
|
||||
|
||||
/**
|
||||
* Force cleanup (optional method for explicit memory management)
|
||||
*/
|
||||
cleanup(): void {
|
||||
// In JavaScript/TypeScript, this is mainly for consistency
|
||||
// The garbage collector will handle actual memory cleanup
|
||||
if (this.cache.size > this.maxSize * 1.5) {
|
||||
// Emergency cleanup if cache somehow exceeds limit
|
||||
const entries = Array.from(this.cache.entries())
|
||||
this.cache.clear()
|
||||
// Keep only the most recent half
|
||||
const keepEntries = entries.slice(-Math.floor(this.maxSize / 2))
|
||||
keepEntries.forEach(([key, value]) => this.cache.set(key, value))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,7 +118,7 @@ if (parentPort) {
|
||||
result = await core.closeMessageCursor(payload.cursor)
|
||||
break
|
||||
case 'execQuery':
|
||||
result = await core.execQuery(payload.kind, payload.path, payload.sql)
|
||||
result = await core.execQuery(payload.kind, payload.path, payload.sql, payload.params)
|
||||
break
|
||||
case 'getEmoticonCdnUrl':
|
||||
result = await core.getEmoticonCdnUrl(payload.dbPath, payload.md5)
|
||||
|
||||
1917
package-lock.json
generated
1917
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,6 @@
|
||||
"jszip": "^3.10.1",
|
||||
"koffi": "^2.9.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"node-llama-cpp": "^3.15.1",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
Binary file not shown.
51
src/App.tsx
51
src/App.tsx
@@ -22,10 +22,9 @@ import SnsPage from './pages/SnsPage'
|
||||
import ContactsPage from './pages/ContactsPage'
|
||||
import ChatHistoryPage from './pages/ChatHistoryPage'
|
||||
import NotificationWindow from './pages/NotificationWindow'
|
||||
import AIChatPage from './pages/AIChatPage'
|
||||
|
||||
import { useAppStore } from './stores/appStore'
|
||||
import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
|
||||
import { themes, useThemeStore, type ThemeId, type ThemeMode } from './stores/themeStore'
|
||||
import * as configService from './services/config'
|
||||
import { Download, X, Shield } from 'lucide-react'
|
||||
import './App.scss'
|
||||
@@ -35,6 +34,7 @@ import UpdateProgressCapsule from './components/UpdateProgressCapsule'
|
||||
import LockScreen from './components/LockScreen'
|
||||
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
||||
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
|
||||
import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal'
|
||||
|
||||
function App() {
|
||||
const navigate = useNavigate()
|
||||
@@ -101,14 +101,27 @@ function App() {
|
||||
|
||||
// 应用主题
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', currentTheme)
|
||||
document.documentElement.setAttribute('data-mode', themeMode)
|
||||
|
||||
// 更新窗口控件颜色以适配主题
|
||||
const symbolColor = themeMode === 'dark' ? '#ffffff' : '#1a1a1a'
|
||||
if (!isOnboardingWindow && !isNotificationWindow) {
|
||||
window.electronAPI.window.setTitleBarOverlay({ symbolColor })
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const applyMode = (mode: ThemeMode, systemDark?: boolean) => {
|
||||
const effectiveMode = mode === 'system' ? (systemDark ?? mq.matches ? 'dark' : 'light') : mode
|
||||
document.documentElement.setAttribute('data-theme', currentTheme)
|
||||
document.documentElement.setAttribute('data-mode', effectiveMode)
|
||||
const symbolColor = effectiveMode === 'dark' ? '#ffffff' : '#1a1a1a'
|
||||
if (!isOnboardingWindow && !isNotificationWindow) {
|
||||
window.electronAPI.window.setTitleBarOverlay({ symbolColor })
|
||||
}
|
||||
}
|
||||
|
||||
applyMode(themeMode)
|
||||
|
||||
// 监听系统主题变化
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
if (useThemeStore.getState().themeMode === 'system') {
|
||||
applyMode('system', e.matches)
|
||||
}
|
||||
}
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow])
|
||||
|
||||
// 读取已保存的主题设置
|
||||
@@ -122,7 +135,7 @@ function App() {
|
||||
if (savedThemeId && themes.some((theme) => theme.id === savedThemeId)) {
|
||||
setTheme(savedThemeId as ThemeId)
|
||||
}
|
||||
if (savedThemeMode === 'light' || savedThemeMode === 'dark') {
|
||||
if (savedThemeMode === 'light' || savedThemeMode === 'dark' || savedThemeMode === 'system') {
|
||||
setThemeMode(savedThemeMode)
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -182,10 +195,12 @@ function App() {
|
||||
if (isNotificationWindow) return // Skip updates in notification window
|
||||
|
||||
const removeUpdateListener = window.electronAPI?.app?.onUpdateAvailable?.((info: any) => {
|
||||
// 发现新版本时自动打开更新弹窗
|
||||
// 发现新版本时保存更新信息,锁定状态下不弹窗,解锁后再显示
|
||||
if (info) {
|
||||
setUpdateInfo({ ...info, hasUpdate: true })
|
||||
setShowUpdateDialog(true)
|
||||
if (!useAppStore.getState().isLocked) {
|
||||
setShowUpdateDialog(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => {
|
||||
@@ -197,6 +212,13 @@ function App() {
|
||||
}
|
||||
}, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow])
|
||||
|
||||
// 解锁后显示暂存的更新弹窗
|
||||
useEffect(() => {
|
||||
if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) {
|
||||
setShowUpdateDialog(true)
|
||||
}
|
||||
}, [isLocked])
|
||||
|
||||
const handleUpdateNow = async () => {
|
||||
setShowUpdateDialog(false)
|
||||
setIsDownloading(true)
|
||||
@@ -291,7 +313,7 @@ function App() {
|
||||
const checkLock = async () => {
|
||||
// 并行获取配置,减少等待
|
||||
const [enabled, useHello] = await Promise.all([
|
||||
configService.getAuthEnabled(),
|
||||
window.electronAPI.auth.verifyEnabled(),
|
||||
configService.getAuthUseHello()
|
||||
])
|
||||
|
||||
@@ -364,6 +386,7 @@ function App() {
|
||||
|
||||
{/* 全局批量转写进度浮窗 */}
|
||||
<BatchTranscribeGlobal />
|
||||
<BatchImageDecryptGlobal />
|
||||
|
||||
{/* 用户协议弹窗 */}
|
||||
{showAgreement && !agreementLoading && (
|
||||
@@ -435,7 +458,7 @@ function App() {
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/home" element={<HomePage />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
<Route path="/ai-chat" element={<AIChatPage />} />
|
||||
|
||||
<Route path="/analytics" element={<AnalyticsWelcomePage />} />
|
||||
<Route path="/analytics/view" element={<AnalyticsPage />} />
|
||||
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />
|
||||
|
||||
133
src/components/BatchImageDecryptGlobal.tsx
Normal file
133
src/components/BatchImageDecryptGlobal.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Loader2, X, Image as ImageIcon, Clock, CheckCircle, XCircle } from 'lucide-react'
|
||||
import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore'
|
||||
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
|
||||
import '../styles/batchTranscribe.scss'
|
||||
|
||||
export const BatchImageDecryptGlobal: React.FC = () => {
|
||||
const {
|
||||
isBatchDecrypting,
|
||||
progress,
|
||||
showToast,
|
||||
showResultToast,
|
||||
result,
|
||||
sessionName,
|
||||
startTime,
|
||||
setShowToast,
|
||||
setShowResultToast
|
||||
} = useBatchImageDecryptStore()
|
||||
|
||||
const voiceToastOccupied = useBatchTranscribeStore(
|
||||
state => state.isBatchTranscribing && state.showToast
|
||||
)
|
||||
|
||||
const [eta, setEta] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBatchDecrypting || !startTime || progress.current === 0) {
|
||||
setEta('')
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime
|
||||
if (elapsed <= 0) return
|
||||
const rate = progress.current / elapsed
|
||||
const remain = progress.total - progress.current
|
||||
if (remain <= 0 || rate <= 0) {
|
||||
setEta('')
|
||||
return
|
||||
}
|
||||
const seconds = Math.ceil((remain / rate) / 1000)
|
||||
if (seconds < 60) {
|
||||
setEta(`${seconds}秒`)
|
||||
} else {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
setEta(`${m}分${s}秒`)
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [isBatchDecrypting, progress.current, progress.total, startTime])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showResultToast) return
|
||||
const timer = window.setTimeout(() => setShowResultToast(false), 6000)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [showResultToast, setShowResultToast])
|
||||
|
||||
const toastBottom = useMemo(() => (voiceToastOccupied ? 148 : 24), [voiceToastOccupied])
|
||||
|
||||
return (
|
||||
<>
|
||||
{showToast && isBatchDecrypting && createPortal(
|
||||
<div className="batch-progress-toast" style={{ bottom: toastBottom }}>
|
||||
<div className="batch-progress-toast-header">
|
||||
<div className="batch-progress-toast-title">
|
||||
<Loader2 size={14} className="spin" />
|
||||
<span>批量解密图片{sessionName ? `(${sessionName})` : ''}</span>
|
||||
</div>
|
||||
<button className="batch-progress-toast-close" onClick={() => setShowToast(false)} title="最小化">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="batch-progress-toast-body">
|
||||
<div className="progress-info-row">
|
||||
<div className="progress-text">
|
||||
<span>{progress.current} / {progress.total}</span>
|
||||
<span className="progress-percent">
|
||||
{progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0}%
|
||||
</span>
|
||||
</div>
|
||||
{eta && (
|
||||
<div className="progress-eta">
|
||||
<Clock size={12} />
|
||||
<span>剩余 {eta}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className="progress-fill"
|
||||
style={{
|
||||
width: `${progress.total > 0 ? (progress.current / progress.total) * 100 : 0}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{showResultToast && createPortal(
|
||||
<div className="batch-progress-toast batch-inline-result-toast" style={{ bottom: toastBottom }}>
|
||||
<div className="batch-progress-toast-header">
|
||||
<div className="batch-progress-toast-title">
|
||||
<ImageIcon size={14} />
|
||||
<span>图片批量解密完成</span>
|
||||
</div>
|
||||
<button className="batch-progress-toast-close" onClick={() => setShowResultToast(false)} title="关闭">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="batch-progress-toast-body">
|
||||
<div className="batch-inline-result-summary">
|
||||
<div className="batch-inline-result-item success">
|
||||
<CheckCircle size={14} />
|
||||
<span>成功 {result.success}</span>
|
||||
</div>
|
||||
<div className={`batch-inline-result-item ${result.fail > 0 ? 'fail' : 'muted'}`}>
|
||||
<XCircle size={14} />
|
||||
<span>失败 {result.fail}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -139,6 +139,18 @@
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,4 +224,68 @@
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.year-month-picker {
|
||||
padding: 4px 0;
|
||||
|
||||
.year-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.year-label {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.month-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
|
||||
.month-btn {
|
||||
padding: 8px 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date())
|
||||
const [selectingStart, setSelectingStart] = useState(true)
|
||||
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 点击外部关闭
|
||||
@@ -185,12 +186,38 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
|
||||
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1))}>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span className="month-year">{currentMonth.getFullYear()}年 {MONTH_NAMES[currentMonth.getMonth()]}</span>
|
||||
<span className="month-year clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
|
||||
{currentMonth.getFullYear()}年 {MONTH_NAMES[currentMonth.getMonth()]}
|
||||
</span>
|
||||
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1))}>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{renderCalendar()}
|
||||
{showYearMonthPicker ? (
|
||||
<div className="year-month-picker">
|
||||
<div className="year-selector">
|
||||
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth()))}>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
<span className="year-label">{currentMonth.getFullYear()}年</span>
|
||||
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth()))}>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="month-grid">
|
||||
{MONTH_NAMES.map((name, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`month-btn ${i === currentMonth.getMonth() ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setCurrentMonth(new Date(currentMonth.getFullYear(), i))
|
||||
setShowYearMonthPicker(false)
|
||||
}}
|
||||
>{name}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : renderCalendar()}
|
||||
<div className="selection-hint">
|
||||
{selectingStart ? '请选择开始日期' : '请选择结束日期'}
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,6 @@ export function GlobalSessionMonitor() {
|
||||
} = useChatStore()
|
||||
|
||||
const sessionsRef = useRef(sessions)
|
||||
|
||||
// 保持 ref 同步
|
||||
useEffect(() => {
|
||||
sessionsRef.current = sessions
|
||||
@@ -47,9 +46,10 @@ export function GlobalSessionMonitor() {
|
||||
return () => {
|
||||
removeListener()
|
||||
}
|
||||
} else {
|
||||
}
|
||||
return () => { }
|
||||
}, []) // 空依赖数组 - 主要是静态的
|
||||
}, [])
|
||||
|
||||
const refreshSessions = async () => {
|
||||
try {
|
||||
|
||||
@@ -75,6 +75,18 @@
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
@@ -97,6 +109,70 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.year-month-picker {
|
||||
padding: 4px 0;
|
||||
|
||||
.year-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.year-label {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.month-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
|
||||
.month-btn {
|
||||
padding: 10px 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
|
||||
@@ -24,6 +24,7 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
||||
const isValidDate = (d: any) => d instanceof Date && !isNaN(d.getTime())
|
||||
const [calendarDate, setCalendarDate] = useState(isValidDate(currentDate) ? new Date(currentDate) : new Date())
|
||||
const [selectedDate, setSelectedDate] = useState(new Date(currentDate))
|
||||
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
@@ -137,7 +138,7 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<span className="current-month">
|
||||
<span className="current-month clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
|
||||
{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月
|
||||
</span>
|
||||
<button
|
||||
@@ -148,6 +149,31 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showYearMonthPicker ? (
|
||||
<div className="year-month-picker">
|
||||
<div className="year-selector">
|
||||
<button className="nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))}>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span className="year-label">{calendarDate.getFullYear()}年</span>
|
||||
<button className="nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))}>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="month-grid">
|
||||
{['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`month-btn ${i === calendarDate.getMonth() ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
|
||||
setShowYearMonthPicker(false)
|
||||
}}
|
||||
>{name}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`calendar-grid ${loadingDates ? 'loading' : ''}`}>
|
||||
{loadingDates && (
|
||||
<div className="calendar-loading">
|
||||
@@ -174,6 +200,7 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="quick-options">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import * as configService from '../services/config'
|
||||
import { ArrowRight, Fingerprint, Lock, ScanFace, ShieldCheck } from 'lucide-react'
|
||||
import './LockScreen.scss'
|
||||
|
||||
@@ -9,14 +8,6 @@ interface LockScreenProps {
|
||||
useHello?: boolean
|
||||
}
|
||||
|
||||
async function sha256(message: string) {
|
||||
const msgBuffer = new TextEncoder().encode(message)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
return hashHex
|
||||
}
|
||||
|
||||
export default function LockScreen({ onUnlock, avatar, useHello = false }: LockScreenProps) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
@@ -49,19 +40,9 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
|
||||
|
||||
const quickStartHello = async () => {
|
||||
try {
|
||||
// 如果父组件已经告诉我们要用 Hello,直接开始,不等待 IPC
|
||||
let shouldUseHello = useHello
|
||||
|
||||
// 为了稳健,如果 prop 没传(虽然现在都传了),再 check 一次 config
|
||||
if (!shouldUseHello) {
|
||||
shouldUseHello = await configService.getAuthUseHello()
|
||||
}
|
||||
|
||||
if (shouldUseHello) {
|
||||
// 标记为可用,显示按钮
|
||||
if (useHello) {
|
||||
setHelloAvailable(true)
|
||||
setShowHello(true)
|
||||
// 立即执行验证 (0延迟)
|
||||
verifyHello()
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -96,25 +77,19 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
|
||||
e?.preventDefault()
|
||||
if (!password || isUnlocked) return
|
||||
|
||||
// 如果正在进行 Hello 验证,它会自动失败或被取代,UI上不用特意取消
|
||||
// 因为 native 调用是模态的或者独立的,我们只要让 JS 状态不对锁住即可
|
||||
|
||||
// 不再检查 isVerifying,因为我们允许打断 Hello
|
||||
setIsVerifying(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const storedHash = await configService.getAuthPassword()
|
||||
const inputHash = await sha256(password)
|
||||
// 发送原始密码到主进程,由主进程验证并解密密钥
|
||||
const result = await window.electronAPI.auth.unlock(password)
|
||||
|
||||
if (inputHash === storedHash) {
|
||||
if (result.success) {
|
||||
handleUnlock()
|
||||
} else {
|
||||
setError('密码错误')
|
||||
setError(result.error || '密码错误')
|
||||
setPassword('')
|
||||
setIsVerifying(false)
|
||||
// 如果密码错误,是否重新触发 Hello?
|
||||
// 用户可能想重试密码,暂时不自动触发
|
||||
}
|
||||
} catch (e) {
|
||||
setError('验证失败')
|
||||
|
||||
@@ -6,6 +6,13 @@
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border-light);
|
||||
|
||||
// 浅色模式下使用不透明背景,避免透明窗口中通知过于透明
|
||||
[data-mode="light"] &,
|
||||
:not([data-mode]) & {
|
||||
background: rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
padding: 12px;
|
||||
@@ -39,7 +46,7 @@
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
|
||||
// Ensure background is solid
|
||||
// 确保背景不透明
|
||||
background: var(--bg-secondary, #2c2c2c);
|
||||
color: var(--text-primary, #ffffff);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock } from 'lucide-react'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import * as configService from '../services/config'
|
||||
|
||||
import './Sidebar.scss'
|
||||
|
||||
function Sidebar() {
|
||||
@@ -12,7 +12,7 @@ function Sidebar() {
|
||||
const setLocked = useAppStore(state => state.setLocked)
|
||||
|
||||
useEffect(() => {
|
||||
configService.getAuthEnabled().then(setAuthEnabled)
|
||||
window.electronAPI.auth.verifyEnabled().then(setAuthEnabled)
|
||||
}, [])
|
||||
|
||||
const isActive = (path: string) => {
|
||||
|
||||
@@ -138,7 +138,7 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
||||
/>
|
||||
<Search size={14} className="search-icon" />
|
||||
{contactSearch && (
|
||||
<X size={14} className="clear-icon" onClick={() => setContactSearch('')} style={{ right: 8, top: 8, position: 'absolute', cursor: 'pointer', color: 'var(--text-tertiary)' }} />
|
||||
<X size={14} className="clear-icon" onClick={() => setContactSearch('')} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Play, Lock, Download } from 'lucide-react'
|
||||
import React, { useState, useRef } from 'react'
|
||||
import { Play, Lock, Download, ImageOff } from 'lucide-react'
|
||||
import { LivePhotoIcon } from '../../components/LivePhotoIcon'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
|
||||
@@ -21,7 +21,9 @@ interface SnsMedia {
|
||||
|
||||
interface SnsMediaGridProps {
|
||||
mediaList: SnsMedia[]
|
||||
postType?: number
|
||||
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
|
||||
onMediaDeleted?: () => void
|
||||
}
|
||||
|
||||
const isSnsVideoUrl = (url?: string): boolean => {
|
||||
@@ -79,9 +81,13 @@ const extractVideoFrame = async (videoPath: string): Promise<string> => {
|
||||
})
|
||||
}
|
||||
|
||||
const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void }) => {
|
||||
const MediaItem = ({ media, postType, onPreview, onMediaDeleted }: { media: SnsMedia; postType?: number; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void; onMediaDeleted?: () => void }) => {
|
||||
const [error, setError] = useState(false)
|
||||
const [deleted, setDeleted] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const markDeleted = () => { setDeleted(true); onMediaDeleted?.() }
|
||||
const retryCount = useRef(0)
|
||||
const [retryKey, setRetryKey] = useState(0)
|
||||
const [thumbSrc, setThumbSrc] = useState<string>('')
|
||||
const [videoPath, setVideoPath] = useState<string>('')
|
||||
const [liveVideoPath, setLiveVideoPath] = useState<string>('')
|
||||
@@ -91,6 +97,18 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
|
||||
const isVideo = isSnsVideoUrl(media.url)
|
||||
const isLive = !!media.livePhoto
|
||||
const targetUrl = media.thumb || media.url
|
||||
// type 7 的朋友圈媒体不需要解密,直接使用原始 URL
|
||||
const skipDecrypt = postType === 7
|
||||
|
||||
// 视频重试:失败时重试最多2次,耗尽才标记删除
|
||||
const videoRetryOrDelete = () => {
|
||||
if (retryCount.current < 2) {
|
||||
retryCount.current++
|
||||
setRetryKey(k => k + 1)
|
||||
} else {
|
||||
markDeleted()
|
||||
}
|
||||
}
|
||||
|
||||
// Simple effect to load image/decrypt
|
||||
// Simple effect to load image/decrypt
|
||||
@@ -104,7 +122,7 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
|
||||
// For images, we proxy to get the local path/base64
|
||||
const result = await window.electronAPI.sns.proxyImage({
|
||||
url: targetUrl,
|
||||
key: media.key
|
||||
key: skipDecrypt ? undefined : media.key
|
||||
})
|
||||
if (cancelled) return
|
||||
|
||||
@@ -112,14 +130,14 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
|
||||
if (result.dataUrl) setThumbSrc(result.dataUrl)
|
||||
else if (result.videoPath) setThumbSrc(`file://${result.videoPath.replace(/\\/g, '/')}`)
|
||||
} else {
|
||||
setThumbSrc(targetUrl)
|
||||
markDeleted()
|
||||
}
|
||||
|
||||
// Pre-load live photo video if needed
|
||||
if (isLive && media.livePhoto?.url) {
|
||||
window.electronAPI.sns.proxyImage({
|
||||
url: media.livePhoto.url,
|
||||
key: media.livePhoto.key || media.key
|
||||
key: skipDecrypt ? undefined : (media.livePhoto.key || media.key)
|
||||
}).then((res: any) => {
|
||||
if (!cancelled && res.success && res.videoPath) {
|
||||
setLiveVideoPath(`file://${res.videoPath.replace(/\\/g, '/')}`)
|
||||
@@ -135,7 +153,7 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
|
||||
// Usually we need to call proxyImage with the video URL to decrypt it to cache
|
||||
const result = await window.electronAPI.sns.proxyImage({
|
||||
url: media.url,
|
||||
key: media.key
|
||||
key: skipDecrypt ? undefined : media.key
|
||||
})
|
||||
|
||||
if (cancelled) return
|
||||
@@ -149,11 +167,11 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
|
||||
if (!cancelled) setThumbSrc(coverDataUrl)
|
||||
} catch (err) {
|
||||
console.error('Frame extraction failed', err)
|
||||
// Fallback to video path if extraction fails, though it might be black
|
||||
// Only set thumbSrc if extraction fails, so we don't override the generated one
|
||||
// 封面提取失败,用视频路径作为 fallback,让 <video> 标签显示
|
||||
if (!cancelled) setThumbSrc(localPath)
|
||||
}
|
||||
} else {
|
||||
console.error('Video decryption for cover failed')
|
||||
videoRetryOrDelete()
|
||||
}
|
||||
|
||||
setIsGeneratingCover(false)
|
||||
@@ -162,7 +180,11 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
if (!cancelled) {
|
||||
setThumbSrc(targetUrl)
|
||||
if (isVideo) {
|
||||
videoRetryOrDelete()
|
||||
} else {
|
||||
markDeleted()
|
||||
}
|
||||
setLoading(false)
|
||||
setIsGeneratingCover(false)
|
||||
}
|
||||
@@ -171,7 +193,7 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
|
||||
|
||||
load()
|
||||
return () => { cancelled = true }
|
||||
}, [media, isVideo, isLive, targetUrl])
|
||||
}, [media, isVideo, isLive, targetUrl, retryKey])
|
||||
|
||||
const handlePreview = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
@@ -182,7 +204,7 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
|
||||
try {
|
||||
const res = await window.electronAPI.sns.proxyImage({
|
||||
url: media.url,
|
||||
key: media.key
|
||||
key: skipDecrypt ? undefined : media.key
|
||||
})
|
||||
if (res.success && res.videoPath) {
|
||||
const local = `file://${res.videoPath.replace(/\\/g, '/')}`
|
||||
@@ -210,7 +232,7 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
|
||||
try {
|
||||
const result = await window.electronAPI.sns.proxyImage({
|
||||
url: media.url,
|
||||
key: media.key
|
||||
key: skipDecrypt ? undefined : media.key
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
@@ -248,6 +270,17 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
|
||||
}
|
||||
}
|
||||
|
||||
if (deleted) {
|
||||
return (
|
||||
<div className="sns-media-item deleted-media">
|
||||
<div className="deleted-placeholder">
|
||||
<ImageOff size={24} />
|
||||
<span>已删除</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`sns-media-item ${isDecrypting ? 'decrypting' : ''}`}
|
||||
@@ -267,15 +300,15 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
|
||||
e.currentTarget.currentTime = 0.1
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
) : thumbSrc ? (
|
||||
<img
|
||||
src={thumbSrc || targetUrl}
|
||||
src={thumbSrc}
|
||||
className="media-image"
|
||||
loading="lazy"
|
||||
onError={() => setError(true)}
|
||||
onError={() => { if (!loading && !isVideo) markDeleted() }}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{isGeneratingCover && (
|
||||
<div className="media-decrypting-mask">
|
||||
@@ -304,7 +337,7 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
|
||||
)
|
||||
}
|
||||
|
||||
export const SnsMediaGrid: React.FC<SnsMediaGridProps> = ({ mediaList, onPreview }) => {
|
||||
export const SnsMediaGrid: React.FC<SnsMediaGridProps> = ({ mediaList, postType, onPreview, onMediaDeleted }) => {
|
||||
if (!mediaList || mediaList.length === 0) return null
|
||||
|
||||
const count = mediaList.length
|
||||
@@ -320,7 +353,7 @@ export const SnsMediaGrid: React.FC<SnsMediaGridProps> = ({ mediaList, onPreview
|
||||
return (
|
||||
<div className={`sns-media-grid ${gridClass}`}>
|
||||
{mediaList.map((media, idx) => (
|
||||
<MediaItem key={idx} media={media} onPreview={onPreview} />
|
||||
<MediaItem key={idx} media={media} postType={postType} onPreview={onPreview} onMediaDeleted={onMediaDeleted} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import { Heart, ChevronRight, ImageIcon, Download, Code, MoreHorizontal } from 'lucide-react'
|
||||
import { Heart, ChevronRight, ImageIcon, Download, Code, MoreHorizontal, Trash2 } from 'lucide-react'
|
||||
import { SnsPost, SnsLinkCardData } from '../../types/sns'
|
||||
import { Avatar } from '../Avatar'
|
||||
import { SnsMediaGrid } from './SnsMediaGrid'
|
||||
import { getEmojiPath } from 'wechat-emojis'
|
||||
|
||||
// Helper functions (extracted from SnsPage.tsx but simplified/reused)
|
||||
const LINK_XML_URL_TAGS = ['url', 'shorturl', 'weburl', 'webpageurl', 'jumpurl']
|
||||
@@ -64,6 +65,21 @@ const isLikelyMediaAssetUrl = (url: string): boolean => {
|
||||
}
|
||||
|
||||
const buildLinkCardData = (post: SnsPost): SnsLinkCardData | null => {
|
||||
// type 3 是链接类型,直接用 media[0] 的 url 和 thumb
|
||||
if (post.type === 3) {
|
||||
const url = post.media[0]?.url || post.linkUrl
|
||||
if (!url) return null
|
||||
const titleCandidates = [
|
||||
post.linkTitle || '',
|
||||
...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS),
|
||||
post.contentDesc || ''
|
||||
]
|
||||
const title = titleCandidates
|
||||
.map((v) => decodeHtmlEntities(v))
|
||||
.find((v) => Boolean(v) && !/^https?:\/\//i.test(v))
|
||||
return { url, title: title || '网页链接', thumb: post.media[0]?.thumb }
|
||||
}
|
||||
|
||||
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
|
||||
if (hasVideoMedia) return null
|
||||
|
||||
@@ -169,6 +185,7 @@ interface SnsPostItemProps {
|
||||
}
|
||||
|
||||
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug }) => {
|
||||
const [mediaDeleted, setMediaDeleted] = useState(false)
|
||||
const linkCard = buildLinkCardData(post)
|
||||
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
|
||||
const showLinkCard = Boolean(linkCard) && post.media.length <= 1 && !hasVideoMedia
|
||||
@@ -187,11 +204,25 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
||||
})
|
||||
}
|
||||
|
||||
// Add extra class for media-only posts (no text) to adjust spacing?
|
||||
// Not strictly needed but good to know
|
||||
// 解析微信表情
|
||||
const renderTextWithEmoji = (text: string) => {
|
||||
if (!text) return text
|
||||
const parts = text.split(/\[(.*?)\]/g)
|
||||
return parts.map((part, index) => {
|
||||
if (index % 2 === 1) {
|
||||
// @ts-ignore
|
||||
const path = getEmojiPath(part as any)
|
||||
if (path) {
|
||||
return <img key={index} src={`${import.meta.env.BASE_URL}${path}`} alt={`[${part}]`} className="inline-emoji" style={{ width: 22, height: 22, verticalAlign: 'bottom', margin: '0 1px' }} />
|
||||
}
|
||||
return `[${part}]`
|
||||
}
|
||||
return part
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sns-post-item">
|
||||
<div className={`sns-post-item ${mediaDeleted ? 'post-deleted' : ''}`}>
|
||||
<div className="post-avatar-col">
|
||||
<Avatar
|
||||
src={post.avatarUrl}
|
||||
@@ -207,16 +238,24 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
||||
<span className="author-name">{decodeHtmlEntities(post.nickname)}</span>
|
||||
<span className="post-time">{formatTime(post.createTime)}</span>
|
||||
</div>
|
||||
<button className="icon-btn-ghost debug-btn" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDebug(post);
|
||||
}} title="查看原始数据">
|
||||
<Code size={14} />
|
||||
</button>
|
||||
<div className="post-header-actions">
|
||||
{mediaDeleted && (
|
||||
<span className="post-deleted-badge">
|
||||
<Trash2 size={12} />
|
||||
<span>已删除</span>
|
||||
</span>
|
||||
)}
|
||||
<button className="icon-btn-ghost debug-btn" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDebug(post);
|
||||
}} title="查看原始数据">
|
||||
<Code size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{post.contentDesc && (
|
||||
<div className="post-text">{decodeHtmlEntities(post.contentDesc)}</div>
|
||||
<div className="post-text">{renderTextWithEmoji(decodeHtmlEntities(post.contentDesc))}</div>
|
||||
)}
|
||||
|
||||
{showLinkCard && linkCard && (
|
||||
@@ -225,7 +264,7 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
||||
|
||||
{showMediaGrid && (
|
||||
<div className="post-media-container">
|
||||
<SnsMediaGrid mediaList={post.media} onPreview={onPreview} />
|
||||
<SnsMediaGrid mediaList={post.media} postType={post.type} onPreview={onPreview} onMediaDeleted={[1, 54].includes(post.type ?? 0) ? () => setMediaDeleted(true) : undefined} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -250,7 +289,7 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
||||
</>
|
||||
)}
|
||||
<span className="comment-colon">:</span>
|
||||
<span className="comment-content">{c.content}</span>
|
||||
<span className="comment-content">{renderTextWithEmoji(c.content)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,552 +0,0 @@
|
||||
// AI 对话页面 - 简约大气风格
|
||||
.ai-chat-page {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--bg-gradient);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
|
||||
.chat-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// ========== 顶部 Header - 已移除 ==========
|
||||
// 模型选择器现已集成到输入框
|
||||
|
||||
|
||||
|
||||
// ========== 聊天区域 ==========
|
||||
.chat-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
// 空状态
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
|
||||
.icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 24px;
|
||||
|
||||
svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 消息列表
|
||||
.messages-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.message-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
max-width: 80%;
|
||||
animation: messageIn 0.3s ease-out;
|
||||
|
||||
// 用户消息
|
||||
&.user {
|
||||
align-self: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.avatar {
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.bubble {
|
||||
background: var(--primary-gradient);
|
||||
color: white;
|
||||
border-radius: 18px 18px 4px 18px;
|
||||
box-shadow: 0 2px 10px color-mix(in srgb, var(--primary) 20%, transparent);
|
||||
|
||||
.content {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AI 消息
|
||||
&.ai {
|
||||
align-self: flex-start;
|
||||
|
||||
.avatar {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.bubble {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 18px 18px 18px 4px;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
padding: 12px 16px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.content,
|
||||
.markdown-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
// Markdown 样式
|
||||
.markdown-content {
|
||||
p {
|
||||
margin: 0 0 0.8em;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 1em 0 0.5em;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: var(--text-primary);
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin: 0.5em 0;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0.3em 0;
|
||||
}
|
||||
|
||||
code {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
margin: 0.8em 0;
|
||||
|
||||
code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 3px solid var(--primary);
|
||||
padding-left: 12px;
|
||||
margin: 0.8em 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 0.8em 0;
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--bg-tertiary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-spacer {
|
||||
height: 100px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 输入区域
|
||||
.input-area {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: calc(100% - 64px);
|
||||
max-width: 800px;
|
||||
z-index: 10;
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
padding: 10px 14px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1),
|
||||
0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
|
||||
}
|
||||
|
||||
textarea {
|
||||
flex: 1;
|
||||
min-height: 24px;
|
||||
max-height: 120px;
|
||||
padding: 8px 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
resize: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.input-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
// 模型选择器
|
||||
.model-selector {
|
||||
position: relative;
|
||||
|
||||
.model-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: auto;
|
||||
height: 36px;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
|
||||
&.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--text-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.loaded {
|
||||
background: color-mix(in srgb, var(--primary) 15%, transparent);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&.loading {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.model-dropdown {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 0;
|
||||
margin-bottom: 8px;
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
animation: dropdownIn 0.2s ease-out;
|
||||
min-width: 140px;
|
||||
|
||||
.model-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
transition: background 0.15s ease;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: color-mix(in srgb, var(--primary) 20%, transparent);
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
|
||||
.check {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.check {
|
||||
margin-left: 8px;
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mode-toggle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
color: var(--text-tertiary);
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: color-mix(in srgb, var(--primary) 15%, transparent);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--primary-gradient);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 8px color-mix(in srgb, var(--primary) 25%, transparent);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 35%, transparent);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes messageIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dropdownIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -1,391 +0,0 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Send, Bot, User, Cpu, ChevronDown, Loader2 } from 'lucide-react'
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'
|
||||
import { engineService, PRESET_MODELS, ModelInfo } from '../services/EngineService'
|
||||
import { MessageBubble } from '../components/MessageBubble'
|
||||
import './AIChatPage.scss'
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'ai';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// 消息数量限制,避免内存过载
|
||||
const MAX_MESSAGES = 200
|
||||
|
||||
export default function AIChatPage() {
|
||||
const [input, setInput] = useState('')
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [isTyping, setIsTyping] = useState(false)
|
||||
const [models, setModels] = useState<ModelInfo[]>([...PRESET_MODELS])
|
||||
const [selectedModel, setSelectedModel] = useState<string | null>(null)
|
||||
const [modelLoaded, setModelLoaded] = useState(false)
|
||||
const [loadingModel, setLoadingModel] = useState(false)
|
||||
const [isThinkingMode, setIsThinkingMode] = useState(true)
|
||||
const [showModelDropdown, setShowModelDropdown] = useState(false)
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 流式渲染优化:使用 ref 缓存内容,使用 RAF 批量更新
|
||||
const streamingContentRef = useRef('')
|
||||
const streamingMessageIdRef = useRef<string | null>(null)
|
||||
const rafIdRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
checkModelsStatus()
|
||||
|
||||
// 初始化Llama服务(延迟初始化,用户进入此页面时启动)
|
||||
const initLlama = async () => {
|
||||
try {
|
||||
await window.electronAPI.llama?.init()
|
||||
console.log('[AIChatPage] Llama service initialized')
|
||||
} catch (e) {
|
||||
console.error('[AIChatPage] Failed to initialize Llama:', e)
|
||||
}
|
||||
}
|
||||
initLlama()
|
||||
|
||||
// 清理函数:组件卸载时释放所有资源
|
||||
return () => {
|
||||
// 取消未完成的 RAF
|
||||
if (rafIdRef.current !== null) {
|
||||
cancelAnimationFrame(rafIdRef.current)
|
||||
rafIdRef.current = null
|
||||
}
|
||||
|
||||
// 清理 engine service 的回调引用
|
||||
engineService.clearCallbacks()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 监听页面卸载事件,确保资源释放
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
// 清理回调和监听器
|
||||
engineService.dispose()
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [])
|
||||
|
||||
// 点击外部关闭下拉框
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setShowModelDropdown(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
// 使用 virtuoso 的 scrollToIndex 方法滚动到底部
|
||||
if (virtuosoRef.current && messages.length > 0) {
|
||||
virtuosoRef.current.scrollToIndex({
|
||||
index: messages.length - 1,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
}, [messages.length])
|
||||
|
||||
const checkModelsStatus = async () => {
|
||||
const updatedModels = await Promise.all(models.map(async (m) => {
|
||||
const exists = await engineService.checkModelExists(m.path)
|
||||
return { ...m, downloaded: exists }
|
||||
}))
|
||||
setModels(updatedModels)
|
||||
|
||||
// Auto-select first available model
|
||||
if (!selectedModel) {
|
||||
const available = updatedModels.find(m => m.downloaded)
|
||||
if (available) {
|
||||
setSelectedModel(available.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 自动加载模型
|
||||
const handleLoadModel = async (modelPath?: string) => {
|
||||
const pathToLoad = modelPath || selectedModel
|
||||
if (!pathToLoad) return false
|
||||
|
||||
setLoadingModel(true)
|
||||
try {
|
||||
await engineService.loadModel(pathToLoad)
|
||||
// Initialize session with system prompt
|
||||
await engineService.createSession("You are a helpful AI assistant.")
|
||||
setModelLoaded(true)
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error("Load failed", e)
|
||||
alert("模型加载失败: " + String(e))
|
||||
return false
|
||||
} finally {
|
||||
setLoadingModel(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 选择模型(如果有多个)
|
||||
const handleSelectModel = (modelPath: string) => {
|
||||
setSelectedModel(modelPath)
|
||||
setShowModelDropdown(false)
|
||||
}
|
||||
|
||||
// 获取可用的已下载模型
|
||||
const availableModels = models.filter(m => m.downloaded)
|
||||
const selectedModelInfo = models.find(m => m.path === selectedModel)
|
||||
|
||||
// 优化的流式更新函数:使用 RAF 批量更新
|
||||
const updateStreamingMessage = useCallback(() => {
|
||||
if (!streamingMessageIdRef.current) return
|
||||
|
||||
setMessages(prev => prev.map(msg =>
|
||||
msg.id === streamingMessageIdRef.current
|
||||
? { ...msg, content: streamingContentRef.current }
|
||||
: msg
|
||||
))
|
||||
|
||||
rafIdRef.current = null
|
||||
}, [])
|
||||
|
||||
// Token 回调:使用 RAF 批量更新 UI
|
||||
const handleToken = useCallback((token: string) => {
|
||||
streamingContentRef.current += token
|
||||
|
||||
// 使用 requestAnimationFrame 批量更新,避免频繁渲染
|
||||
if (rafIdRef.current === null) {
|
||||
rafIdRef.current = requestAnimationFrame(updateStreamingMessage)
|
||||
}
|
||||
}, [updateStreamingMessage])
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || isTyping) return
|
||||
|
||||
// 如果模型未加载,先自动加载
|
||||
if (!modelLoaded) {
|
||||
if (!selectedModel) {
|
||||
alert("请先下载模型(设置页面)")
|
||||
return
|
||||
}
|
||||
const loaded = await handleLoadModel()
|
||||
if (!loaded) return
|
||||
}
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: input,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
setMessages(prev => {
|
||||
const newMessages = [...prev, userMsg]
|
||||
// 限制消息数量,避免内存过载
|
||||
return newMessages.length > MAX_MESSAGES
|
||||
? newMessages.slice(-MAX_MESSAGES)
|
||||
: newMessages
|
||||
})
|
||||
setInput('')
|
||||
setIsTyping(true)
|
||||
|
||||
// Reset textarea height
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
}
|
||||
|
||||
const aiMsgId = (Date.now() + 1).toString()
|
||||
streamingContentRef.current = ''
|
||||
streamingMessageIdRef.current = aiMsgId
|
||||
|
||||
// Optimistic update for AI message start
|
||||
setMessages(prev => {
|
||||
const newMessages = [...prev, {
|
||||
id: aiMsgId,
|
||||
role: 'ai' as const,
|
||||
content: '',
|
||||
timestamp: Date.now()
|
||||
}]
|
||||
return newMessages.length > MAX_MESSAGES
|
||||
? newMessages.slice(-MAX_MESSAGES)
|
||||
: newMessages
|
||||
})
|
||||
|
||||
// Append thinking command based on mode
|
||||
const msgWithSuffix = input + (isThinkingMode ? " /think" : " /no_think")
|
||||
|
||||
try {
|
||||
await engineService.chat(msgWithSuffix, handleToken, { thinking: isThinkingMode })
|
||||
} catch (e) {
|
||||
console.error("Chat failed", e)
|
||||
setMessages(prev => [...prev, {
|
||||
id: Date.now().toString(),
|
||||
role: 'ai',
|
||||
content: "❌ Error: Failed to get response from AI.",
|
||||
timestamp: Date.now()
|
||||
}])
|
||||
} finally {
|
||||
setIsTyping(false)
|
||||
streamingMessageIdRef.current = null
|
||||
|
||||
// 确保最终状态同步
|
||||
if (rafIdRef.current !== null) {
|
||||
cancelAnimationFrame(rafIdRef.current)
|
||||
updateStreamingMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染模型选择按钮(集成在输入框作为下拉项)
|
||||
const renderModelSelector = () => {
|
||||
// 没有可用模型
|
||||
if (availableModels.length === 0) {
|
||||
return (
|
||||
<button
|
||||
className="model-btn disabled"
|
||||
title="请先在设置页面下载模型"
|
||||
>
|
||||
<Bot size={16} />
|
||||
<span>无模型</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// 只有一个模型,直接显示
|
||||
if (availableModels.length === 1) {
|
||||
return (
|
||||
<button
|
||||
className={`model-btn ${modelLoaded ? 'loaded' : ''} ${loadingModel ? 'loading' : ''}`}
|
||||
title={modelLoaded ? "模型已就绪" : "发送消息时自动加载"}
|
||||
>
|
||||
{loadingModel ? (
|
||||
<Loader2 size={16} className="spin" />
|
||||
) : (
|
||||
<Bot size={16} />
|
||||
)}
|
||||
<span>{loadingModel ? '加载中' : selectedModelInfo?.name || '模型'}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// 多个模型,显示下拉选择
|
||||
return (
|
||||
<div className="model-selector" ref={dropdownRef}>
|
||||
<button
|
||||
className={`model-btn ${modelLoaded ? 'loaded' : ''} ${loadingModel ? 'loading' : ''}`}
|
||||
onClick={() => !loadingModel && setShowModelDropdown(!showModelDropdown)}
|
||||
title="点击选择模型"
|
||||
>
|
||||
{loadingModel ? (
|
||||
<Loader2 size={16} className="spin" />
|
||||
) : (
|
||||
<Bot size={16} />
|
||||
)}
|
||||
<span>{loadingModel ? '加载中' : selectedModelInfo?.name || '选择模型'}</span>
|
||||
<ChevronDown size={13} className={showModelDropdown ? 'rotate' : ''} />
|
||||
</button>
|
||||
|
||||
{showModelDropdown && (
|
||||
<div className="model-dropdown">
|
||||
{availableModels.map(model => (
|
||||
<div
|
||||
key={model.path}
|
||||
className={`model-option ${selectedModel === model.path ? 'active' : ''}`}
|
||||
onClick={() => handleSelectModel(model.path)}
|
||||
>
|
||||
<span>{model.name}</span>
|
||||
{selectedModel === model.path && (
|
||||
<span className="check">✓</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-chat-page">
|
||||
<div className="chat-main">
|
||||
{messages.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="icon">
|
||||
<Bot size={40} />
|
||||
</div>
|
||||
<h2>AI 为你服务</h2>
|
||||
<p>
|
||||
{availableModels.length === 0
|
||||
? "请先在设置页面下载模型"
|
||||
: "输入消息开始对话,模型将自动加载"
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
data={messages}
|
||||
className="messages-list"
|
||||
initialTopMostItemIndex={messages.length - 1}
|
||||
followOutput="smooth"
|
||||
itemContent={(index, message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
)}
|
||||
components={{
|
||||
Footer: () => <div className="list-spacer" />
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="input-area">
|
||||
<div className="input-wrapper">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={e => {
|
||||
setInput(e.target.value)
|
||||
e.target.style.height = 'auto'
|
||||
e.target.style.height = `${Math.min(e.target.scrollHeight, 120)}px`
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
// Reset height after send
|
||||
if (textareaRef.current) textareaRef.current.style.height = 'auto'
|
||||
}
|
||||
}}
|
||||
placeholder={availableModels.length === 0 ? "请先下载模型..." : "输入消息..."}
|
||||
disabled={availableModels.length === 0 || loadingModel}
|
||||
rows={1}
|
||||
/>
|
||||
<div className="input-actions">
|
||||
{renderModelSelector()}
|
||||
<button
|
||||
className={`mode-toggle ${isThinkingMode ? 'active' : ''}`}
|
||||
onClick={() => setIsThinkingMode(!isThinkingMode)}
|
||||
title={isThinkingMode ? "深度思考模式已开启" : "深度思考模式已关闭"}
|
||||
disabled={availableModels.length === 0}
|
||||
>
|
||||
<Cpu size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="send-btn"
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || availableModels.length === 0 || isTyping || loadingModel}
|
||||
>
|
||||
<Send size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1041,12 +1041,13 @@
|
||||
// 链接卡片消息样式
|
||||
.link-message {
|
||||
width: 280px;
|
||||
background: var(--card-bg);
|
||||
background: var(--card-inner-bg);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
@@ -1114,19 +1115,362 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.appmsg-meta-badge {
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
color: var(--primary);
|
||||
background: rgba(127, 127, 127, 0.08);
|
||||
border: 1px solid rgba(127, 127, 127, 0.18);
|
||||
border-radius: 999px;
|
||||
padding: 3px 7px;
|
||||
align-self: flex-start;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.link-desc-block {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.appmsg-url-line {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&.appmsg-rich-card {
|
||||
.link-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.link-thumb.theme-adaptive,
|
||||
.miniapp-thumb.theme-adaptive {
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
|
||||
[data-mode="dark"] {
|
||||
|
||||
.link-thumb.theme-adaptive,
|
||||
.miniapp-thumb.theme-adaptive {
|
||||
filter: invert(1) hue-rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
// 适配发送出去的消息中的链接卡片
|
||||
.message-bubble.sent .link-message {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--sent-card-bg);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
|
||||
&:hover {
|
||||
background: var(--primary-hover);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.link-title {
|
||||
color: var(--text-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.link-desc {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.appmsg-url-line {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
// ============= 专属消息卡片 =============
|
||||
|
||||
// 红包卡片
|
||||
.hongbao-message {
|
||||
width: 240px;
|
||||
background: linear-gradient(135deg, #e25b4a 0%, #c94535 100%);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
cursor: default;
|
||||
|
||||
.hongbao-icon {
|
||||
flex-shrink: 0;
|
||||
|
||||
svg {
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
}
|
||||
|
||||
.hongbao-info {
|
||||
flex: 1;
|
||||
color: white;
|
||||
|
||||
.hongbao-greeting {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.hongbao-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 礼物卡片
|
||||
.gift-message {
|
||||
width: 240px;
|
||||
background: linear-gradient(135deg, #f7a8b8 0%, #e88fa0 100%);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
cursor: default;
|
||||
|
||||
.gift-img {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 10px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.gift-info {
|
||||
color: white;
|
||||
|
||||
.gift-wish {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.gift-price {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.gift-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 视频号卡片
|
||||
.channel-video-card {
|
||||
width: 200px;
|
||||
background: var(--card-inner-bg);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.channel-video-cover {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
background: #000;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.channel-video-cover-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.channel-video-duration {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
right: 6px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.channel-video-info {
|
||||
padding: 8px 10px;
|
||||
|
||||
.channel-video-title {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.channel-video-author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
.channel-video-avatar {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 音乐卡片
|
||||
.music-message {
|
||||
width: 240px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.music-cover {
|
||||
width: 80px;
|
||||
align-self: stretch;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.music-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: 10px;
|
||||
|
||||
.music-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.music-artist {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.music-source {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 位置消息卡片
|
||||
.location-message {
|
||||
width: 240px;
|
||||
background: var(--card-inner-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.location-text {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.location-icon {
|
||||
flex-shrink: 0;
|
||||
color: #e25b4a;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.location-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.location-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.location-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.location-map {
|
||||
position: relative;
|
||||
height: 100px;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 暗色模式下地图瓦片反色
|
||||
[data-mode="dark"] {
|
||||
.location-map img {
|
||||
filter: invert(1) hue-rotate(180deg) brightness(0.9) contrast(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1288,6 +1632,21 @@
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.empty-chat-inline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 60px 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 14px;
|
||||
|
||||
svg {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.message-list * {
|
||||
-webkit-app-region: no-drag !important;
|
||||
}
|
||||
@@ -1497,6 +1856,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 卡片类消息:气泡变透明,让卡片自己做视觉容器(仅直接子元素,排除引用消息内的卡片)
|
||||
.message-bubble .bubble-content:has(> .link-message),
|
||||
.message-bubble .bubble-content:has(> .card-message),
|
||||
.message-bubble .bubble-content:has(> .chat-record-message),
|
||||
.message-bubble .bubble-content:has(> .official-message),
|
||||
.message-bubble .bubble-content:has(> .channel-video-card),
|
||||
.message-bubble .bubble-content:has(> .location-message) {
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
.emoji-image {
|
||||
max-width: 120px;
|
||||
max-height: 120px;
|
||||
@@ -1566,6 +1939,23 @@
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
.media-badge.live {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
left: auto;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border-radius: 50%;
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.image-update-button {
|
||||
@@ -2455,10 +2845,17 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
padding: 12px 14px;
|
||||
background: var(--card-inner-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
min-width: 200px;
|
||||
transition: opacity 0.2s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
flex-shrink: 0;
|
||||
@@ -2483,6 +2880,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 聊天记录消息 (合并转发)
|
||||
.chat-record-message {
|
||||
background: var(--card-inner-bg) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
transition: opacity 0.2s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
// 通话消息
|
||||
.call-message {
|
||||
display: flex;
|
||||
@@ -2720,12 +3129,14 @@
|
||||
|
||||
.card-message,
|
||||
.chat-record-message,
|
||||
.miniapp-message {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
.miniapp-message,
|
||||
.appmsg-rich-card {
|
||||
background: var(--sent-card-bg);
|
||||
|
||||
.card-name,
|
||||
.miniapp-title,
|
||||
.source-name {
|
||||
.source-name,
|
||||
.link-title {
|
||||
color: white;
|
||||
}
|
||||
|
||||
@@ -2733,7 +3144,9 @@
|
||||
.miniapp-label,
|
||||
.chat-record-item,
|
||||
.chat-record-meta-line,
|
||||
.chat-record-desc {
|
||||
.chat-record-desc,
|
||||
.link-desc,
|
||||
.appmsg-url-line {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
@@ -2746,6 +3159,12 @@
|
||||
.chat-record-more {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.appmsg-meta-badge {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.call-message {
|
||||
@@ -3203,4 +3622,234 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.miniapp-message-rich {
|
||||
.miniapp-thumb {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 名片消息样式
|
||||
.card-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--card-inner-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-wxid {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.card-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 4px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
}
|
||||
|
||||
// 聊天记录消息外观
|
||||
.chat-record-message {
|
||||
background: var(--card-inner-bg) !important;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover) !important;
|
||||
}
|
||||
|
||||
.chat-record-list {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.6;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
|
||||
.chat-record-item {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
.source-name {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-record-more {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// 公众号文章图文消息外观 (大图模式)
|
||||
.official-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--card-inner-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 240px;
|
||||
max-width: 320px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.official-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
|
||||
.official-avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.official-avatar-placeholder {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.official-name {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.official-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.official-cover-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-top: 42.5%; // ~2.35:1 aspectRatio standard for WeChat article covers
|
||||
background: var(--bg-secondary);
|
||||
overflow: hidden;
|
||||
|
||||
.official-cover {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.official-title-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding: 24px 12px 10px;
|
||||
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.7));
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.official-title-text {
|
||||
padding: 0 12px 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.official-digest {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
padding: 0 12px 12px;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,11 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
|
||||
import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore'
|
||||
import type { ChatSession, Message } from '../types/models'
|
||||
import { getEmojiPath } from 'wechat-emojis'
|
||||
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
|
||||
import { LivePhotoIcon } from '../components/LivePhotoIcon'
|
||||
import { AnimatedStreamingText } from '../components/AnimatedStreamingText'
|
||||
import JumpToDateDialog from '../components/JumpToDateDialog'
|
||||
import * as configService from '../services/config'
|
||||
@@ -26,6 +28,12 @@ interface XmlField {
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface BatchImageDecryptCandidate {
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
createTime?: number
|
||||
}
|
||||
|
||||
// 尝试解析 XML 为可编辑字段
|
||||
function parseXmlToFields(xml: string): XmlField[] {
|
||||
const fields: XmlField[] = []
|
||||
@@ -281,6 +289,8 @@ function ChatPage(_props: ChatPageProps) {
|
||||
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
|
||||
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false)
|
||||
const [hasInitialMessages, setHasInitialMessages] = useState(false)
|
||||
const [noMessageTable, setNoMessageTable] = useState(false)
|
||||
const [fallbackDisplayName, setFallbackDisplayName] = useState<string | null>(null)
|
||||
const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false)
|
||||
const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null)
|
||||
|
||||
@@ -298,11 +308,16 @@ function ChatPage(_props: ChatPageProps) {
|
||||
|
||||
// 批量语音转文字相关状态(进度/结果 由全局 store 管理)
|
||||
const { isBatchTranscribing, progress: batchTranscribeProgress, showToast: showBatchProgress, startTranscribe, updateProgress, finishTranscribe, setShowToast: setShowBatchProgress } = useBatchTranscribeStore()
|
||||
const { isBatchDecrypting, progress: batchDecryptProgress, startDecrypt, updateProgress: updateDecryptProgress, finishDecrypt, setShowToast: setShowBatchDecryptToast } = useBatchImageDecryptStore()
|
||||
const [showBatchConfirm, setShowBatchConfirm] = useState(false)
|
||||
const [batchVoiceCount, setBatchVoiceCount] = useState(0)
|
||||
const [batchVoiceMessages, setBatchVoiceMessages] = useState<Message[] | null>(null)
|
||||
const [batchVoiceDates, setBatchVoiceDates] = useState<string[]>([])
|
||||
const [batchSelectedDates, setBatchSelectedDates] = useState<Set<string>>(new Set())
|
||||
const [showBatchDecryptConfirm, setShowBatchDecryptConfirm] = useState(false)
|
||||
const [batchImageMessages, setBatchImageMessages] = useState<BatchImageDecryptCandidate[] | null>(null)
|
||||
const [batchImageDates, setBatchImageDates] = useState<string[]>([])
|
||||
const [batchImageSelectedDates, setBatchImageSelectedDates] = useState<Set<string>>(new Set())
|
||||
|
||||
// 批量删除相关状态
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
@@ -857,6 +872,10 @@ function ChatPage(_props: ChatPageProps) {
|
||||
if (result.success && result.messages) {
|
||||
if (offset === 0) {
|
||||
setMessages(result.messages)
|
||||
if (result.messages.length === 0) {
|
||||
setNoMessageTable(true)
|
||||
setHasMoreMessages(false)
|
||||
}
|
||||
|
||||
// 预取发送者信息:在关闭加载遮罩前处理
|
||||
const unreadCount = session?.unreadCount ?? 0
|
||||
@@ -929,7 +948,7 @@ function ChatPage(_props: ChatPageProps) {
|
||||
}
|
||||
setCurrentOffset(offset + result.messages.length)
|
||||
} else if (!result.success) {
|
||||
setConnectionError(result.error || '加载消息失败')
|
||||
setNoMessageTable(true)
|
||||
setHasMoreMessages(false)
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -1247,6 +1266,7 @@ function ChatPage(_props: ChatPageProps) {
|
||||
useEffect(() => {
|
||||
if (currentSessionId !== prevSessionRef.current) {
|
||||
prevSessionRef.current = currentSessionId
|
||||
setNoMessageTable(false)
|
||||
if (initialRevealTimerRef.current !== null) {
|
||||
window.clearTimeout(initialRevealTimerRef.current)
|
||||
initialRevealTimerRef.current = null
|
||||
@@ -1260,10 +1280,11 @@ function ChatPage(_props: ChatPageProps) {
|
||||
}, [currentSessionId, messages.length, isLoadingMessages])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentSessionId && messages.length === 0 && !isLoadingMessages && !isLoadingMore) {
|
||||
if (currentSessionId && messages.length === 0 && !isLoadingMessages && !isLoadingMore && !noMessageTable) {
|
||||
setHasInitialMessages(false)
|
||||
loadMessages(currentSessionId, 0)
|
||||
}
|
||||
}, [currentSessionId, messages.length, isLoadingMessages, isLoadingMore])
|
||||
}, [currentSessionId, messages.length, isLoadingMessages, isLoadingMore, noMessageTable])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -1327,8 +1348,35 @@ function ChatPage(_props: ChatPageProps) {
|
||||
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`
|
||||
}, [])
|
||||
|
||||
// 获取当前会话信息
|
||||
const currentSession = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined
|
||||
// 获取当前会话信息(从通讯录跳转时可能不在 sessions 列表中,构造 fallback)
|
||||
const currentSession = (() => {
|
||||
const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined
|
||||
if (found || !currentSessionId) return found
|
||||
return {
|
||||
username: currentSessionId,
|
||||
type: 0,
|
||||
unreadCount: 0,
|
||||
summary: '',
|
||||
sortTimestamp: 0,
|
||||
lastTimestamp: 0,
|
||||
lastMsgType: 0,
|
||||
displayName: fallbackDisplayName || currentSessionId,
|
||||
} as ChatSession
|
||||
})()
|
||||
|
||||
// 从通讯录跳转时,会话不在列表中,主动加载联系人显示名称
|
||||
useEffect(() => {
|
||||
if (!currentSessionId) return
|
||||
const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined
|
||||
if (found) {
|
||||
setFallbackDisplayName(null)
|
||||
return
|
||||
}
|
||||
loadContactInfoBatch([currentSessionId]).then(() => {
|
||||
const cached = senderAvatarCache.get(currentSessionId)
|
||||
if (cached?.displayName) setFallbackDisplayName(cached.displayName)
|
||||
})
|
||||
}, [currentSessionId, sessions])
|
||||
|
||||
// 判断是否为群聊
|
||||
const isGroupChat = (username: string) => username.includes('@chatroom')
|
||||
@@ -1398,6 +1446,37 @@ function ChatPage(_props: ChatPageProps) {
|
||||
setShowBatchConfirm(true)
|
||||
}, [sessions, currentSessionId, isBatchTranscribing])
|
||||
|
||||
const handleBatchDecrypt = useCallback(async () => {
|
||||
if (!currentSessionId || isBatchDecrypting) return
|
||||
const session = sessions.find(s => s.username === currentSessionId)
|
||||
if (!session) {
|
||||
alert('未找到当前会话')
|
||||
return
|
||||
}
|
||||
|
||||
const result = await window.electronAPI.chat.getAllImageMessages(currentSessionId)
|
||||
if (!result.success || !result.images) {
|
||||
alert(`获取图片消息失败: ${result.error || '未知错误'}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (result.images.length === 0) {
|
||||
alert('当前会话没有图片消息')
|
||||
return
|
||||
}
|
||||
|
||||
const dateSet = new Set<string>()
|
||||
result.images.forEach((img: BatchImageDecryptCandidate) => {
|
||||
if (img.createTime) dateSet.add(new Date(img.createTime * 1000).toISOString().slice(0, 10))
|
||||
})
|
||||
const sortedDates = Array.from(dateSet).sort((a, b) => b.localeCompare(a))
|
||||
|
||||
setBatchImageMessages(result.images)
|
||||
setBatchImageDates(sortedDates)
|
||||
setBatchImageSelectedDates(new Set(sortedDates))
|
||||
setShowBatchDecryptConfirm(true)
|
||||
}, [currentSessionId, isBatchDecrypting, sessions])
|
||||
|
||||
const handleExportCurrentSession = useCallback(() => {
|
||||
if (!currentSessionId) return
|
||||
navigate('/export', {
|
||||
@@ -1521,6 +1600,88 @@ function ChatPage(_props: ChatPageProps) {
|
||||
const selectAllBatchDates = useCallback(() => setBatchSelectedDates(new Set(batchVoiceDates)), [batchVoiceDates])
|
||||
const clearAllBatchDates = useCallback(() => setBatchSelectedDates(new Set()), [])
|
||||
|
||||
const confirmBatchDecrypt = useCallback(async () => {
|
||||
if (!currentSessionId) return
|
||||
|
||||
const selected = batchImageSelectedDates
|
||||
if (selected.size === 0) {
|
||||
alert('请至少选择一个日期')
|
||||
return
|
||||
}
|
||||
|
||||
const images = (batchImageMessages || []).filter(img =>
|
||||
img.createTime && selected.has(new Date(img.createTime * 1000).toISOString().slice(0, 10))
|
||||
)
|
||||
if (images.length === 0) {
|
||||
alert('所选日期下没有图片消息')
|
||||
return
|
||||
}
|
||||
|
||||
const session = sessions.find(s => s.username === currentSessionId)
|
||||
if (!session) return
|
||||
|
||||
setShowBatchDecryptConfirm(false)
|
||||
setBatchImageMessages(null)
|
||||
setBatchImageDates([])
|
||||
setBatchImageSelectedDates(new Set())
|
||||
|
||||
startDecrypt(images.length, session.displayName || session.username)
|
||||
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const img = images[i]
|
||||
try {
|
||||
const r = await window.electronAPI.image.decrypt({
|
||||
sessionId: session.username,
|
||||
imageMd5: img.imageMd5,
|
||||
imageDatName: img.imageDatName,
|
||||
force: false
|
||||
})
|
||||
if (r?.success) successCount++
|
||||
else failCount++
|
||||
} catch {
|
||||
failCount++
|
||||
}
|
||||
|
||||
updateDecryptProgress(i + 1, images.length)
|
||||
if (i % 5 === 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
}
|
||||
|
||||
finishDecrypt(successCount, failCount)
|
||||
}, [batchImageMessages, batchImageSelectedDates, currentSessionId, finishDecrypt, sessions, startDecrypt, updateDecryptProgress])
|
||||
|
||||
const batchImageCountByDate = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
if (!batchImageMessages) return map
|
||||
batchImageMessages.forEach(img => {
|
||||
if (!img.createTime) return
|
||||
const d = new Date(img.createTime * 1000).toISOString().slice(0, 10)
|
||||
map.set(d, (map.get(d) ?? 0) + 1)
|
||||
})
|
||||
return map
|
||||
}, [batchImageMessages])
|
||||
|
||||
const batchImageSelectedCount = useMemo(() => {
|
||||
if (!batchImageMessages) return 0
|
||||
return batchImageMessages.filter(img =>
|
||||
img.createTime && batchImageSelectedDates.has(new Date(img.createTime * 1000).toISOString().slice(0, 10))
|
||||
).length
|
||||
}, [batchImageMessages, batchImageSelectedDates])
|
||||
|
||||
const toggleBatchImageDate = useCallback((date: string) => {
|
||||
setBatchImageSelectedDates(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(date)) next.delete(date)
|
||||
else next.add(date)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
const selectAllBatchImageDates = useCallback(() => setBatchImageSelectedDates(new Set(batchImageDates)), [batchImageDates])
|
||||
const clearAllBatchImageDates = useCallback(() => setBatchImageSelectedDates(new Set()), [])
|
||||
|
||||
const lastSelectedIdRef = useRef<number | null>(null)
|
||||
|
||||
const handleToggleSelection = useCallback((localId: number, isShiftKey: boolean = false) => {
|
||||
@@ -1960,6 +2121,26 @@ function ChatPage(_props: ChatPageProps) {
|
||||
<Mic size={18} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className={`icon-btn batch-decrypt-btn${isBatchDecrypting ? ' transcribing' : ''}`}
|
||||
onClick={() => {
|
||||
if (isBatchDecrypting) {
|
||||
setShowBatchDecryptToast(true)
|
||||
} else {
|
||||
handleBatchDecrypt()
|
||||
}
|
||||
}}
|
||||
disabled={!currentSessionId}
|
||||
title={isBatchDecrypting
|
||||
? `批量解密中 (${batchDecryptProgress.current}/${batchDecryptProgress.total}),点击查看进度`
|
||||
: '批量解密图片'}
|
||||
>
|
||||
{isBatchDecrypting ? (
|
||||
<Loader2 size={18} className="spin" />
|
||||
) : (
|
||||
<ImageIcon size={18} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn jump-to-time-btn"
|
||||
onClick={async () => {
|
||||
@@ -2048,6 +2229,13 @@ function ChatPage(_props: ChatPageProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoadingMessages && messages.length === 0 && !hasMoreMessages && (
|
||||
<div className="empty-chat-inline">
|
||||
<MessageSquare size={32} />
|
||||
<span>该联系人没有聊天记录</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, index) => {
|
||||
const prevMsg = index > 0 ? messages[index - 1] : undefined
|
||||
const showDateDivider = shouldShowDateDivider(msg, prevMsg)
|
||||
@@ -2318,6 +2506,66 @@ function ChatPage(_props: ChatPageProps) {
|
||||
document.body
|
||||
)}
|
||||
{/* 消息右键菜单 */}
|
||||
{showBatchDecryptConfirm && createPortal(
|
||||
<div className="batch-modal-overlay" onClick={() => setShowBatchDecryptConfirm(false)}>
|
||||
<div className="batch-modal-content batch-confirm-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="batch-modal-header">
|
||||
<ImageIcon size={20} />
|
||||
<h3>批量解密图片</h3>
|
||||
</div>
|
||||
<div className="batch-modal-body">
|
||||
<p>选择要解密的日期(仅显示有图片的日期),然后开始解密。</p>
|
||||
{batchImageDates.length > 0 && (
|
||||
<div className="batch-dates-list-wrap">
|
||||
<div className="batch-dates-actions">
|
||||
<button type="button" className="batch-dates-btn" onClick={selectAllBatchImageDates}>全选</button>
|
||||
<button type="button" className="batch-dates-btn" onClick={clearAllBatchImageDates}>取消全选</button>
|
||||
</div>
|
||||
<ul className="batch-dates-list">
|
||||
{batchImageDates.map(dateStr => {
|
||||
const count = batchImageCountByDate.get(dateStr) ?? 0
|
||||
const checked = batchImageSelectedDates.has(dateStr)
|
||||
return (
|
||||
<li key={dateStr}>
|
||||
<label className="batch-date-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleBatchImageDate(dateStr)}
|
||||
/>
|
||||
<span className="batch-date-label">{formatBatchDateLabel(dateStr)}</span>
|
||||
<span className="batch-date-count">{count} 张图片</span>
|
||||
</label>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div className="batch-info">
|
||||
<div className="info-item">
|
||||
<span className="label">已选:</span>
|
||||
<span className="value">{batchImageSelectedDates.size} 天,共 {batchImageSelectedCount} 张图片</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="batch-warning">
|
||||
<AlertCircle size={16} />
|
||||
<span>批量解密可能需要较长时间,进行中会在右下角显示非阻塞进度浮层。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="batch-modal-footer">
|
||||
<button className="btn-secondary" onClick={() => setShowBatchDecryptConfirm(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button className="btn-primary" onClick={confirmBatchDecrypt}>
|
||||
<ImageIcon size={16} />
|
||||
开始解密
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
{contextMenu && createPortal(
|
||||
<>
|
||||
<div className="context-menu-overlay" onClick={() => setContextMenu(null)}
|
||||
@@ -2583,6 +2831,7 @@ function MessageBubble({
|
||||
const [imageInView, setImageInView] = useState(false)
|
||||
const imageForceHdAttempted = useRef<string | null>(null)
|
||||
const imageForceHdPending = useRef(false)
|
||||
const [imageLiveVideoPath, setImageLiveVideoPath] = useState<string | undefined>(undefined)
|
||||
const [voiceError, setVoiceError] = useState(false)
|
||||
const [voiceLoading, setVoiceLoading] = useState(false)
|
||||
const [isVoicePlaying, setIsVoicePlaying] = useState(false)
|
||||
@@ -2811,7 +3060,8 @@ function MessageBubble({
|
||||
imageDataUrlCache.set(imageCacheKey, result.localPath)
|
||||
setImageLocalPath(result.localPath)
|
||||
setImageHasUpdate(false)
|
||||
return
|
||||
if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2822,7 +3072,7 @@ function MessageBubble({
|
||||
imageDataUrlCache.set(imageCacheKey, dataUrl)
|
||||
setImageLocalPath(dataUrl)
|
||||
setImageHasUpdate(false)
|
||||
return
|
||||
return { success: true, localPath: dataUrl } as any
|
||||
}
|
||||
if (!silent) setImageError(true)
|
||||
} catch {
|
||||
@@ -2830,6 +3080,7 @@ function MessageBubble({
|
||||
} finally {
|
||||
if (!silent) setImageLoading(false)
|
||||
}
|
||||
return { success: false } as any
|
||||
}, [isImage, imageLoading, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64])
|
||||
|
||||
const triggerForceHd = useCallback(() => {
|
||||
@@ -2860,6 +3111,55 @@ function MessageBubble({
|
||||
void requestImageDecrypt()
|
||||
}, [message.imageDatName, message.imageMd5, message.localId, requestImageDecrypt, session.username])
|
||||
|
||||
const handleOpenImageViewer = useCallback(async () => {
|
||||
if (!imageLocalPath) return
|
||||
|
||||
let finalImagePath = imageLocalPath
|
||||
let finalLiveVideoPath = imageLiveVideoPath || undefined
|
||||
|
||||
// If current cache is a thumbnail, wait for a silent force-HD decrypt before opening viewer.
|
||||
if (imageHasUpdate) {
|
||||
try {
|
||||
const upgraded = await requestImageDecrypt(true, true)
|
||||
if (upgraded?.success && upgraded.localPath) {
|
||||
finalImagePath = upgraded.localPath
|
||||
finalLiveVideoPath = upgraded.liveVideoPath || finalLiveVideoPath
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
// One more resolve helps when background/batch decrypt has produced a clearer image or live video
|
||||
// but local component state hasn't caught up yet.
|
||||
if (message.imageMd5 || message.imageDatName) {
|
||||
try {
|
||||
const resolved = await window.electronAPI.image.resolveCache({
|
||||
sessionId: session.username,
|
||||
imageMd5: message.imageMd5 || undefined,
|
||||
imageDatName: message.imageDatName
|
||||
})
|
||||
if (resolved?.success && resolved.localPath) {
|
||||
finalImagePath = resolved.localPath
|
||||
finalLiveVideoPath = resolved.liveVideoPath || finalLiveVideoPath
|
||||
imageDataUrlCache.set(imageCacheKey, resolved.localPath)
|
||||
setImageLocalPath(resolved.localPath)
|
||||
if (resolved.liveVideoPath) setImageLiveVideoPath(resolved.liveVideoPath)
|
||||
setImageHasUpdate(Boolean(resolved.hasUpdate))
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
void window.electronAPI.window.openImageViewerWindow(finalImagePath, finalLiveVideoPath)
|
||||
}, [
|
||||
imageHasUpdate,
|
||||
imageLiveVideoPath,
|
||||
imageLocalPath,
|
||||
imageCacheKey,
|
||||
message.imageDatName,
|
||||
message.imageMd5,
|
||||
requestImageDecrypt,
|
||||
session.username
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (imageClickTimerRef.current) {
|
||||
@@ -2878,7 +3178,7 @@ function MessageBubble({
|
||||
sessionId: session.username,
|
||||
imageMd5: message.imageMd5 || undefined,
|
||||
imageDatName: message.imageDatName
|
||||
}).then((result: { success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }) => {
|
||||
}).then((result: { success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }) => {
|
||||
if (cancelled) return
|
||||
if (result.success && result.localPath) {
|
||||
imageDataUrlCache.set(imageCacheKey, result.localPath)
|
||||
@@ -2886,6 +3186,7 @@ function MessageBubble({
|
||||
setImageLocalPath(result.localPath)
|
||||
setImageError(false)
|
||||
}
|
||||
if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath)
|
||||
setImageHasUpdate(Boolean(result.hasUpdate))
|
||||
}
|
||||
}).catch(() => { })
|
||||
@@ -3380,15 +3681,15 @@ function MessageBubble({
|
||||
src={imageLocalPath}
|
||||
alt="图片"
|
||||
className="image-message"
|
||||
onClick={() => {
|
||||
if (imageHasUpdate) {
|
||||
void requestImageDecrypt(true, true)
|
||||
}
|
||||
void window.electronAPI.window.openImageViewerWindow(imageLocalPath)
|
||||
}}
|
||||
onClick={() => { void handleOpenImageViewer() }}
|
||||
onLoad={() => setImageError(false)}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
{imageLiveVideoPath && (
|
||||
<div className="media-badge live">
|
||||
<LivePhotoIcon size={14} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -3643,16 +3944,24 @@ function MessageBubble({
|
||||
// 名片消息
|
||||
if (isCard) {
|
||||
const cardName = message.cardNickname || message.cardUsername || '未知联系人'
|
||||
const cardAvatar = message.cardAvatarUrl
|
||||
return (
|
||||
<div className="card-message">
|
||||
<div className="card-icon">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
{cardAvatar ? (
|
||||
<img src={cardAvatar} alt="" style={{ width: '40px', height: '40px', objectFit: 'cover', borderRadius: '8px' }} referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-info">
|
||||
<div className="card-name">{cardName}</div>
|
||||
{message.cardUsername && message.cardUsername !== message.cardNickname && (
|
||||
<div className="card-wxid">微信号: {message.cardUsername}</div>
|
||||
)}
|
||||
<div className="card-label">个人名片</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3671,7 +3980,346 @@ function MessageBubble({
|
||||
)
|
||||
}
|
||||
|
||||
// 位置消息
|
||||
if (message.localType === 48) {
|
||||
const raw = message.rawContent || ''
|
||||
const poiname = raw.match(/poiname="([^"]*)"/)?.[1] || message.locationPoiname || '位置'
|
||||
const label = raw.match(/label="([^"]*)"/)?.[1] || message.locationLabel || ''
|
||||
const lat = parseFloat(raw.match(/x="([^"]*)"/)?.[1] || String(message.locationLat || 0))
|
||||
const lng = parseFloat(raw.match(/y="([^"]*)"/)?.[1] || String(message.locationLng || 0))
|
||||
const zoom = 15
|
||||
const tileX = Math.floor((lng + 180) / 360 * Math.pow(2, zoom))
|
||||
const latRad = lat * Math.PI / 180
|
||||
const tileY = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * Math.pow(2, zoom))
|
||||
const mapTileUrl = (lat && lng)
|
||||
? `https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x=${tileX}&y=${tileY}&z=${zoom}`
|
||||
: ''
|
||||
return (
|
||||
<div className="location-message" onClick={() => window.electronAPI.shell.openExternal(`https://uri.amap.com/marker?position=${lng},${lat}&name=${encodeURIComponent(poiname || label)}`)}>
|
||||
<div className="location-text">
|
||||
<div className="location-icon">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="location-info">
|
||||
{poiname && <div className="location-name">{poiname}</div>}
|
||||
{label && <div className="location-label">{label}</div>}
|
||||
</div>
|
||||
</div>
|
||||
{mapTileUrl && (
|
||||
<div className="location-map">
|
||||
<img src={mapTileUrl} alt="地图" referrerPolicy="no-referrer" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 链接消息 (AppMessage)
|
||||
const appMsgRichPreview = (() => {
|
||||
const rawXml = message.rawContent || ''
|
||||
if (!rawXml || (!rawXml.includes('<appmsg') && !rawXml.includes('<appmsg'))) return null
|
||||
|
||||
let doc: Document | null = null
|
||||
const getDoc = () => {
|
||||
if (doc) return doc
|
||||
try {
|
||||
const start = rawXml.indexOf('<msg>')
|
||||
const xml = start >= 0 ? rawXml.slice(start) : rawXml
|
||||
doc = new DOMParser().parseFromString(xml, 'text/xml')
|
||||
} catch {
|
||||
doc = null
|
||||
}
|
||||
return doc
|
||||
}
|
||||
const q = (selector: string) => getDoc()?.querySelector(selector)?.textContent?.trim() || ''
|
||||
|
||||
const xmlType = message.xmlType || q('appmsg > type') || q('type')
|
||||
|
||||
// type 57: 引用回复消息,解析 refermsg 渲染为引用样式
|
||||
if (xmlType === '57') {
|
||||
const replyText = q('title') || cleanMessageContent(message.parsedContent) || ''
|
||||
const referContent = q('refermsg > content') || ''
|
||||
const referSender = 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>
|
||||
)
|
||||
}
|
||||
|
||||
const title = message.linkTitle || q('title') || cleanMessageContent(message.parsedContent) || 'Card'
|
||||
const desc = message.appMsgDesc || q('des')
|
||||
const url = message.linkUrl || q('url')
|
||||
const thumbUrl = message.linkThumb || message.appMsgThumbUrl || q('thumburl') || q('cdnthumburl') || q('cover') || q('coverurl')
|
||||
const musicUrl = message.appMsgMusicUrl || message.appMsgDataUrl || q('musicurl') || q('playurl') || q('dataurl') || q('lowurl')
|
||||
const sourceName = message.appMsgSourceName || q('sourcename')
|
||||
const sourceDisplayName = q('sourcedisplayname') || ''
|
||||
const appName = message.appMsgAppName || q('appname')
|
||||
const sourceUsername = message.appMsgSourceUsername || q('sourceusername')
|
||||
const finderName =
|
||||
message.finderNickname ||
|
||||
message.finderUsername ||
|
||||
q('findernickname') ||
|
||||
q('finder_nickname') ||
|
||||
q('finderusername') ||
|
||||
q('finder_username')
|
||||
|
||||
const lower = rawXml.toLowerCase()
|
||||
|
||||
const kind = message.appMsgKind || (
|
||||
(xmlType === '2001' || lower.includes('hongbao')) ? 'red-packet'
|
||||
: (xmlType === '115' ? 'gift'
|
||||
: ((xmlType === '33' || xmlType === '36') ? 'miniapp'
|
||||
: (((xmlType === '5' || xmlType === '49') && (sourceUsername.startsWith('gh_') || !!sourceName || appName.includes('公众号'))) ? 'official-link'
|
||||
: (xmlType === '51' ? 'finder'
|
||||
: (xmlType === '3' ? 'music'
|
||||
: ((xmlType === '5' || xmlType === '49') ? 'link' // Fallback for standard links
|
||||
: (!!musicUrl ? 'music' : '')))))))
|
||||
)
|
||||
|
||||
if (!kind) return null
|
||||
|
||||
// 对视频号提取真实标题,避免出现 "当前版本不支持该内容"
|
||||
let displayTitle = title
|
||||
if (kind === 'finder' && (!displayTitle || displayTitle.includes('不支持'))) {
|
||||
try {
|
||||
const d = new DOMParser().parseFromString(rawXml, 'text/xml')
|
||||
displayTitle = d.querySelector('finderFeed desc')?.textContent?.trim() || desc || ''
|
||||
} catch {
|
||||
displayTitle = desc || ''
|
||||
}
|
||||
}
|
||||
|
||||
const openExternal = (e: React.MouseEvent, nextUrl?: string) => {
|
||||
if (!nextUrl) return
|
||||
e.stopPropagation()
|
||||
if (window.electronAPI?.shell?.openExternal) {
|
||||
window.electronAPI.shell.openExternal(nextUrl)
|
||||
} else {
|
||||
window.open(nextUrl, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
const metaLabel =
|
||||
kind === 'red-packet' ? '红包'
|
||||
: kind === 'finder' ? (finderName || '视频号')
|
||||
: kind === 'location' ? '位置'
|
||||
: kind === 'music' ? (sourceName || appName || '音乐')
|
||||
: (sourceName || appName || (sourceUsername.startsWith('gh_') ? '公众号' : ''))
|
||||
|
||||
const renderCard = (cardKind: string, clickableUrl?: string) => (
|
||||
<div
|
||||
className={`link-message appmsg-rich-card ${cardKind}`}
|
||||
onClick={clickableUrl ? (e) => openExternal(e, clickableUrl) : undefined}
|
||||
title={clickableUrl}
|
||||
>
|
||||
<div className="link-header">
|
||||
<div className="link-title" title={title}>{title}</div>
|
||||
{metaLabel ? <div className="appmsg-meta-badge">{metaLabel}</div> : null}
|
||||
</div>
|
||||
<div className="link-body">
|
||||
<div className="link-desc-block">
|
||||
{desc ? <div className="link-desc" title={desc}>{desc}</div> : null}
|
||||
</div>
|
||||
{thumbUrl ? (
|
||||
<img
|
||||
src={thumbUrl}
|
||||
alt=""
|
||||
className={`link-thumb${((cardKind === 'miniapp') || /\.svg(?:$|\?)/i.test(thumbUrl)) ? ' theme-adaptive' : ''}`}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className={`link-thumb-placeholder ${cardKind}`}>{cardKind.slice(0, 2).toUpperCase()}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (kind === 'red-packet') {
|
||||
// 专属红包卡片
|
||||
const greeting = (() => {
|
||||
try {
|
||||
const d = getDoc()
|
||||
if (!d) return ''
|
||||
return d.querySelector('receivertitle')?.textContent?.trim() ||
|
||||
d.querySelector('sendertitle')?.textContent?.trim() || ''
|
||||
} catch { return '' }
|
||||
})()
|
||||
return (
|
||||
<div className="hongbao-message">
|
||||
<div className="hongbao-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
|
||||
<rect x="4" y="6" width="32" height="28" rx="4" fill="white" fillOpacity="0.3" />
|
||||
<rect x="4" y="6" width="32" height="14" rx="4" fill="white" fillOpacity="0.2" />
|
||||
<circle cx="20" cy="20" r="6" fill="white" fillOpacity="0.4" />
|
||||
<text x="20" y="24" textAnchor="middle" fill="white" fontSize="12" fontWeight="bold">¥</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="hongbao-info">
|
||||
<div className="hongbao-greeting">{greeting || '恭喜发财,大吉大利'}</div>
|
||||
<div className="hongbao-label">微信红包</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (kind === 'gift') {
|
||||
// 礼物卡片
|
||||
const giftImg = message.giftImageUrl || thumbUrl
|
||||
const giftWish = message.giftWish || title || '送你一份心意'
|
||||
const giftPriceRaw = message.giftPrice
|
||||
const giftPriceYuan = giftPriceRaw ? (parseInt(giftPriceRaw) / 100).toFixed(2) : ''
|
||||
return (
|
||||
<div className="gift-message">
|
||||
{giftImg && <img className="gift-img" src={giftImg} alt="" referrerPolicy="no-referrer" />}
|
||||
<div className="gift-info">
|
||||
<div className="gift-wish">{giftWish}</div>
|
||||
{giftPriceYuan && <div className="gift-price">¥{giftPriceYuan}</div>}
|
||||
<div className="gift-label">微信礼物</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (kind === 'finder') {
|
||||
// 视频号专属卡片
|
||||
const coverUrl = message.finderCoverUrl || thumbUrl
|
||||
const duration = message.finderDuration
|
||||
const authorName = finderName || ''
|
||||
const authorAvatar = message.finderAvatar
|
||||
const fmtDuration = duration ? `${Math.floor(duration / 60)}:${String(duration % 60).padStart(2, '0')}` : ''
|
||||
return (
|
||||
<div className="channel-video-card" onClick={url ? (e) => openExternal(e, url) : undefined}>
|
||||
<div className="channel-video-cover">
|
||||
{coverUrl ? (
|
||||
<img src={coverUrl} alt="" referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
<div className="channel-video-cover-placeholder">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{fmtDuration && <span className="channel-video-duration">{fmtDuration}</span>}
|
||||
</div>
|
||||
<div className="channel-video-info">
|
||||
<div className="channel-video-title">{displayTitle || '视频号视频'}</div>
|
||||
<div className="channel-video-author">
|
||||
{authorAvatar && <img className="channel-video-avatar" src={authorAvatar} alt="" referrerPolicy="no-referrer" />}
|
||||
<span>{authorName || '视频号'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (kind === 'music') {
|
||||
// 音乐专属卡片
|
||||
const albumUrl = message.musicAlbumUrl || thumbUrl
|
||||
const playUrl = message.musicUrl || musicUrl || url
|
||||
const songTitle = title || '未知歌曲'
|
||||
const artist = desc || ''
|
||||
const appLabel = sourceName || appName || ''
|
||||
return (
|
||||
<div className="music-message" onClick={playUrl ? (e) => openExternal(e, playUrl) : undefined}>
|
||||
<div className="music-cover">
|
||||
{albumUrl ? (
|
||||
<img src={albumUrl} alt="" referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="music-info">
|
||||
<div className="music-title">{songTitle}</div>
|
||||
{artist && <div className="music-artist">{artist}</div>}
|
||||
{appLabel && <div className="music-source">{appLabel}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (kind === 'official-link') {
|
||||
const authorAvatar = q('publisher > headimg') || q('brand_info > headimgurl') || q('appmsg > avatar') || q('headimgurl') || message.cardAvatarUrl
|
||||
const authorName = sourceDisplayName || q('publisher > nickname') || sourceName || appName || '公众号'
|
||||
const coverPic = q('mmreader > category > item > cover') || thumbUrl
|
||||
const digest = q('mmreader > category > item > digest') || desc
|
||||
const articleTitle = q('mmreader > category > item > title') || title
|
||||
|
||||
return (
|
||||
<div className="official-message" onClick={url ? (e) => openExternal(e, url) : undefined}>
|
||||
<div className="official-header">
|
||||
{authorAvatar ? (
|
||||
<img src={authorAvatar} alt="" className="official-avatar" referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
<div className="official-avatar-placeholder">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<span className="official-name">{authorName}</span>
|
||||
</div>
|
||||
<div className="official-body">
|
||||
{coverPic ? (
|
||||
<div className="official-cover-wrapper">
|
||||
<img src={coverPic} alt="" className="official-cover" referrerPolicy="no-referrer" />
|
||||
<div className="official-title-overlay">{articleTitle}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="official-title-text">{articleTitle}</div>
|
||||
)}
|
||||
{digest && <div className="official-digest">{digest}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (kind === 'link') return renderCard('link', url || undefined)
|
||||
if (kind === 'card') return renderCard('card', url || undefined)
|
||||
if (kind === 'miniapp') {
|
||||
return (
|
||||
<div className="miniapp-message miniapp-message-rich">
|
||||
<div className="miniapp-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="miniapp-info">
|
||||
<div className="miniapp-title">{title}</div>
|
||||
<div className="miniapp-label">{metaLabel || '小程序'}</div>
|
||||
</div>
|
||||
{thumbUrl ? (
|
||||
<img
|
||||
src={thumbUrl}
|
||||
alt=""
|
||||
className={`miniapp-thumb${/\.svg(?:$|\?)/i.test(thumbUrl) ? ' theme-adaptive' : ''}`}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})()
|
||||
|
||||
if (appMsgRichPreview) {
|
||||
return appMsgRichPreview
|
||||
}
|
||||
|
||||
const isAppMsg = message.rawContent?.includes('<appmsg') || (message.parsedContent && message.parsedContent.includes('<appmsg'))
|
||||
|
||||
if (isAppMsg) {
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
|
||||
// 左侧联系人面板
|
||||
.contacts-panel {
|
||||
width: 380px;
|
||||
min-width: 380px;
|
||||
width: 350px;
|
||||
min-width: 350px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border-color);
|
||||
@@ -55,6 +55,11 @@
|
||||
.spin {
|
||||
animation: contactsSpin 1s linear infinite;
|
||||
}
|
||||
|
||||
&.export-mode-btn.active {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,11 +115,11 @@
|
||||
}
|
||||
|
||||
.type-filters {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
padding: 0 20px 16px;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
max-width: 300px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
@@ -231,8 +236,8 @@
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s;
|
||||
margin-bottom: 4px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
@@ -242,6 +247,10 @@
|
||||
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.contact-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -334,6 +343,94 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 右侧详情面板内的联系人资料
|
||||
.detail-profile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 20px;
|
||||
|
||||
.detail-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
|
||||
img { width: 100%; height: 100%; object-fit: cover; }
|
||||
span { color: #fff; font-size: 28px; font-weight: 600; }
|
||||
}
|
||||
|
||||
.detail-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.detail-info-list {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: var(--text-tertiary);
|
||||
min-width: 48px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
|
||||
.goto-chat-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover { background: var(--primary-hover); }
|
||||
}
|
||||
|
||||
.empty-detail {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
// 右侧设置面板
|
||||
.settings-panel {
|
||||
flex: 1;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX } from 'lucide-react'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import './ContactsPage.scss'
|
||||
|
||||
interface ContactInfo {
|
||||
@@ -8,7 +10,7 @@ interface ContactInfo {
|
||||
remark?: string
|
||||
nickname?: string
|
||||
avatarUrl?: string
|
||||
type: 'friend' | 'group' | 'official' | 'other'
|
||||
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||
}
|
||||
|
||||
function ContactsPage() {
|
||||
@@ -19,10 +21,17 @@ function ContactsPage() {
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
const [contactTypes, setContactTypes] = useState({
|
||||
friends: true,
|
||||
groups: true,
|
||||
officials: true
|
||||
groups: false,
|
||||
officials: false,
|
||||
deletedFriends: false
|
||||
})
|
||||
|
||||
// 导出模式与查看详情
|
||||
const [exportMode, setExportMode] = useState(false)
|
||||
const [selectedContact, setSelectedContact] = useState<ContactInfo | null>(null)
|
||||
const navigate = useNavigate()
|
||||
const { setCurrentSession } = useChatStore()
|
||||
|
||||
// 导出相关状态
|
||||
const [exportFormat, setExportFormat] = useState<'json' | 'csv' | 'vcf'>('json')
|
||||
const [exportAvatars, setExportAvatars] = useState(true)
|
||||
@@ -85,6 +94,7 @@ function ContactsPage() {
|
||||
if (c.type === 'friend' && !contactTypes.friends) return false
|
||||
if (c.type === 'group' && !contactTypes.groups) return false
|
||||
if (c.type === 'official' && !contactTypes.officials) return false
|
||||
if (c.type === 'former_friend' && !contactTypes.deletedFriends) return false
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -154,6 +164,7 @@ function ContactsPage() {
|
||||
case 'friend': return <User size={14} />
|
||||
case 'group': return <Users size={14} />
|
||||
case 'official': return <MessageSquare size={14} />
|
||||
case 'former_friend': return <UserX size={14} />
|
||||
default: return <User size={14} />
|
||||
}
|
||||
}
|
||||
@@ -163,6 +174,7 @@ function ContactsPage() {
|
||||
case 'friend': return '好友'
|
||||
case 'group': return '群聊'
|
||||
case 'official': return '公众号'
|
||||
case 'former_friend': return '曾经的好友'
|
||||
default: return '其他'
|
||||
}
|
||||
}
|
||||
@@ -236,9 +248,18 @@ function ContactsPage() {
|
||||
<div className="contacts-panel">
|
||||
<div className="panel-header">
|
||||
<h2>通讯录</h2>
|
||||
<button className="icon-btn" onClick={loadContacts} disabled={isLoading}>
|
||||
<RefreshCw size={18} className={isLoading ? 'spin' : ''} />
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button
|
||||
className={`icon-btn export-mode-btn ${exportMode ? 'active' : ''}`}
|
||||
onClick={() => { setExportMode(!exportMode); setSelectedContact(null) }}
|
||||
title={exportMode ? '退出导出模式' : '进入导出模式'}
|
||||
>
|
||||
<Download size={18} />
|
||||
</button>
|
||||
<button className="icon-btn" onClick={loadContacts} disabled={isLoading}>
|
||||
<RefreshCw size={18} className={isLoading ? 'spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="search-bar">
|
||||
@@ -258,49 +279,41 @@ function ContactsPage() {
|
||||
|
||||
<div className="type-filters">
|
||||
<label className={`filter-chip ${contactTypes.friends ? 'active' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={contactTypes.friends}
|
||||
onChange={e => setContactTypes({ ...contactTypes, friends: e.target.checked })}
|
||||
/>
|
||||
<User size={16} />
|
||||
<span>好友</span>
|
||||
<input type="checkbox" checked={contactTypes.friends} onChange={e => setContactTypes({ ...contactTypes, friends: e.target.checked })} />
|
||||
<User size={16} /><span>好友</span>
|
||||
</label>
|
||||
<label className={`filter-chip ${contactTypes.groups ? 'active' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={contactTypes.groups}
|
||||
onChange={e => setContactTypes({ ...contactTypes, groups: e.target.checked })}
|
||||
/>
|
||||
<Users size={16} />
|
||||
<span>群聊</span>
|
||||
<input type="checkbox" checked={contactTypes.groups} onChange={e => setContactTypes({ ...contactTypes, groups: e.target.checked })} />
|
||||
<Users size={16} /><span>群聊</span>
|
||||
</label>
|
||||
<label className={`filter-chip ${contactTypes.officials ? 'active' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={contactTypes.officials}
|
||||
onChange={e => setContactTypes({ ...contactTypes, officials: e.target.checked })}
|
||||
/>
|
||||
<MessageSquare size={16} />
|
||||
<span>公众号</span>
|
||||
<input type="checkbox" checked={contactTypes.officials} onChange={e => setContactTypes({ ...contactTypes, officials: e.target.checked })} />
|
||||
<MessageSquare size={16} /><span>公众号</span>
|
||||
</label>
|
||||
<label className={`filter-chip ${contactTypes.deletedFriends ? 'active' : ''}`}>
|
||||
<input type="checkbox" checked={contactTypes.deletedFriends} onChange={e => setContactTypes({ ...contactTypes, deletedFriends: e.target.checked })} />
|
||||
<UserX size={16} /><span>曾经的好友</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="contacts-count">
|
||||
共 {filteredContacts.length} 个联系人
|
||||
</div>
|
||||
<div className="selection-toolbar">
|
||||
<label className="checkbox-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allFilteredSelected}
|
||||
onChange={e => toggleAllFilteredSelected(e.target.checked)}
|
||||
disabled={filteredContacts.length === 0}
|
||||
/>
|
||||
<span>全选当前筛选结果</span>
|
||||
</label>
|
||||
<span className="selection-count">已选 {selectedUsernames.size}(当前筛选 {selectedInFilteredCount} / {filteredContacts.length})</span>
|
||||
</div>
|
||||
|
||||
{exportMode && (
|
||||
<div className="selection-toolbar">
|
||||
<label className="checkbox-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allFilteredSelected}
|
||||
onChange={e => toggleAllFilteredSelected(e.target.checked)}
|
||||
disabled={filteredContacts.length === 0}
|
||||
/>
|
||||
<span>全选当前筛选结果</span>
|
||||
</label>
|
||||
<span className="selection-count">已选 {selectedUsernames.size}(当前筛选 {selectedInFilteredCount} / {filteredContacts.length})</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="loading-state">
|
||||
@@ -314,20 +327,29 @@ function ContactsPage() {
|
||||
) : (
|
||||
<div className="contacts-list">
|
||||
{filteredContacts.map(contact => {
|
||||
const isSelected = selectedUsernames.has(contact.username)
|
||||
const isChecked = selectedUsernames.has(contact.username)
|
||||
const isActive = !exportMode && selectedContact?.username === contact.username
|
||||
return (
|
||||
<div
|
||||
key={contact.username}
|
||||
className={`contact-item ${isSelected ? 'selected' : ''}`}
|
||||
onClick={() => toggleContactSelected(contact.username, !isSelected)}
|
||||
className={`contact-item ${exportMode && isChecked ? 'selected' : ''} ${isActive ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
if (exportMode) {
|
||||
toggleContactSelected(contact.username, !isChecked)
|
||||
} else {
|
||||
setSelectedContact(isActive ? null : contact)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label className="contact-select" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={e => toggleContactSelected(contact.username, e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
{exportMode && (
|
||||
<label className="contact-select" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={e => toggleContactSelected(contact.username, e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<div className="contact-avatar">
|
||||
{contact.avatarUrl ? (
|
||||
<img src={contact.avatarUrl} alt="" />
|
||||
@@ -352,90 +374,129 @@ function ContactsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧:导出设置 */}
|
||||
<div className="settings-panel">
|
||||
<div className="panel-header">
|
||||
<h2>导出设置</h2>
|
||||
</div>
|
||||
{/* 右侧面板 */}
|
||||
{exportMode ? (
|
||||
<div className="settings-panel">
|
||||
<div className="panel-header">
|
||||
<h2>导出设置</h2>
|
||||
</div>
|
||||
|
||||
<div className="settings-content">
|
||||
<div className="setting-section">
|
||||
<h3>导出格式</h3>
|
||||
<div className="format-select" ref={formatDropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`select-trigger ${showFormatSelect ? 'open' : ''}`}
|
||||
onClick={() => setShowFormatSelect(!showFormatSelect)}
|
||||
>
|
||||
<span className="select-value">{getOptionLabel(exportFormat)}</span>
|
||||
<ChevronDown size={16} />
|
||||
<div className="settings-content">
|
||||
<div className="setting-section">
|
||||
<h3>导出格式</h3>
|
||||
<div className="format-select" ref={formatDropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`select-trigger ${showFormatSelect ? 'open' : ''}`}
|
||||
onClick={() => setShowFormatSelect(!showFormatSelect)}
|
||||
>
|
||||
<span className="select-value">{getOptionLabel(exportFormat)}</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{showFormatSelect && (
|
||||
<div className="select-dropdown">
|
||||
{exportFormatOptions.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`select-option ${exportFormat === option.value ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setExportFormat(option.value as 'json' | 'csv' | 'vcf')
|
||||
setShowFormatSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
<span className="option-desc">{option.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-section">
|
||||
<h3>导出选项</h3>
|
||||
<label className="checkbox-item">
|
||||
<input type="checkbox" checked={exportAvatars} onChange={e => setExportAvatars(e.target.checked)} />
|
||||
<span>导出头像</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-section">
|
||||
<h3>导出位置</h3>
|
||||
<div className="export-path-display">
|
||||
<FolderOpen size={16} />
|
||||
<span>{exportFolder || '未设置'}</span>
|
||||
</div>
|
||||
<button className="select-folder-btn" onClick={selectExportFolder}>
|
||||
<FolderOpen size={16} />
|
||||
<span>选择导出目录</span>
|
||||
</button>
|
||||
{showFormatSelect && (
|
||||
<div className="select-dropdown">
|
||||
{exportFormatOptions.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`select-option ${exportFormat === option.value ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setExportFormat(option.value as 'json' | 'csv' | 'vcf')
|
||||
setShowFormatSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
<span className="option-desc">{option.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="export-action">
|
||||
<button
|
||||
className="export-btn"
|
||||
onClick={startExport}
|
||||
disabled={!exportFolder || isExporting || selectedUsernames.size === 0}
|
||||
>
|
||||
{isExporting ? (
|
||||
<><Loader2 size={18} className="spin" /><span>导出中...</span></>
|
||||
) : (
|
||||
<><Download size={18} /><span>开始导出</span></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-section">
|
||||
<h3>导出选项</h3>
|
||||
<label className="checkbox-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportAvatars}
|
||||
onChange={e => setExportAvatars(e.target.checked)}
|
||||
/>
|
||||
<span>导出头像</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-section">
|
||||
<h3>导出位置</h3>
|
||||
<div className="export-path-display">
|
||||
<FolderOpen size={16} />
|
||||
<span>{exportFolder || '未设置'}</span>
|
||||
</div>
|
||||
<button className="select-folder-btn" onClick={selectExportFolder}>
|
||||
<FolderOpen size={16} />
|
||||
<span>选择导出目录</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : selectedContact ? (
|
||||
<div className="settings-panel">
|
||||
<div className="panel-header">
|
||||
<h2>联系人详情</h2>
|
||||
</div>
|
||||
<div className="settings-content">
|
||||
<div className="detail-profile">
|
||||
<div className="detail-avatar">
|
||||
{selectedContact.avatarUrl ? (
|
||||
<img src={selectedContact.avatarUrl} alt="" />
|
||||
) : (
|
||||
<span>{getAvatarLetter(selectedContact.displayName)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="detail-name">{selectedContact.displayName}</div>
|
||||
<div className={`contact-type ${selectedContact.type}`}>
|
||||
{getContactTypeIcon(selectedContact.type)}
|
||||
<span>{getContactTypeName(selectedContact.type)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="export-action">
|
||||
<button
|
||||
className="export-btn"
|
||||
onClick={startExport}
|
||||
disabled={!exportFolder || isExporting || selectedUsernames.size === 0}
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<Loader2 size={18} className="spin" />
|
||||
<span>导出中...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download size={18} />
|
||||
<span>开始导出</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<div className="detail-info-list">
|
||||
<div className="detail-row"><span className="detail-label">用户名</span><span className="detail-value">{selectedContact.username}</span></div>
|
||||
<div className="detail-row"><span className="detail-label">昵称</span><span className="detail-value">{selectedContact.nickname || selectedContact.displayName}</span></div>
|
||||
{selectedContact.remark && <div className="detail-row"><span className="detail-label">备注</span><span className="detail-value">{selectedContact.remark}</span></div>}
|
||||
<div className="detail-row"><span className="detail-label">类型</span><span className="detail-value">{getContactTypeName(selectedContact.type)}</span></div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="goto-chat-btn"
|
||||
onClick={() => {
|
||||
setCurrentSession(selectedContact.username)
|
||||
navigate('/chat')
|
||||
}}
|
||||
>
|
||||
<MessageCircle size={18} />
|
||||
<span>查看聊天记录</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="settings-panel">
|
||||
<div className="empty-detail">
|
||||
<User size={48} />
|
||||
<span>点击左侧联系人查看详情</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -955,6 +955,18 @@
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1015,6 +1027,70 @@
|
||||
}
|
||||
}
|
||||
|
||||
.year-month-picker {
|
||||
padding: 4px 0;
|
||||
|
||||
.year-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.year-label {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.calendar-nav-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.month-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
|
||||
.month-btn {
|
||||
padding: 10px 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.date-picker-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
@@ -53,6 +53,7 @@ function ExportPage() {
|
||||
const [showDatePicker, setShowDatePicker] = useState(false)
|
||||
const [calendarDate, setCalendarDate] = useState(new Date())
|
||||
const [selectingStart, setSelectingStart] = useState(true)
|
||||
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
|
||||
const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false)
|
||||
const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false)
|
||||
const [showPreExportDialog, setShowPreExportDialog] = useState(false)
|
||||
@@ -1047,7 +1048,7 @@ function ExportPage() {
|
||||
|
||||
{/* 日期选择弹窗 */}
|
||||
{showDatePicker && (
|
||||
<div className="export-overlay" onClick={() => setShowDatePicker(false)}>
|
||||
<div className="export-overlay" onClick={() => { setShowDatePicker(false); setShowYearMonthPicker(false) }}>
|
||||
<div className="date-picker-modal" onClick={e => e.stopPropagation()}>
|
||||
<h3>选择时间范围</h3>
|
||||
<p style={{ fontSize: '13px', color: 'var(--text-secondary)', margin: '8px 0 16px 0' }}>
|
||||
@@ -1122,7 +1123,7 @@ function ExportPage() {
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<span className="calendar-month">
|
||||
<span className="calendar-month clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
|
||||
{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月
|
||||
</span>
|
||||
<button
|
||||
@@ -1132,6 +1133,32 @@ function ExportPage() {
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{showYearMonthPicker ? (
|
||||
<div className="year-month-picker">
|
||||
<div className="year-selector">
|
||||
<button className="calendar-nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))}>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span className="year-label">{calendarDate.getFullYear()}年</span>
|
||||
<button className="calendar-nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))}>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="month-grid">
|
||||
{['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`month-btn ${i === calendarDate.getMonth() ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
|
||||
setShowYearMonthPicker(false)
|
||||
}}
|
||||
>{name}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="calendar-weekdays">
|
||||
{['日', '一', '二', '三', '四', '五', '六'].map(day => (
|
||||
<div key={day} className="calendar-weekday">{day}</div>
|
||||
@@ -1163,12 +1190,14 @@ function ExportPage() {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="date-picker-actions">
|
||||
<button className="cancel-btn" onClick={() => setShowDatePicker(false)}>
|
||||
<button className="cancel-btn" onClick={() => { setShowDatePicker(false); setShowYearMonthPicker(false) }}>
|
||||
取消
|
||||
</button>
|
||||
<button className="confirm-btn" onClick={() => setShowDatePicker(false)}>
|
||||
<button className="confirm-btn" onClick={() => { setShowDatePicker(false); setShowYearMonthPicker(false) }}>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,18 @@
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.live-play-btn {
|
||||
&.active {
|
||||
background: rgba(var(--primary-rgb, 76, 132, 255), 0.16);
|
||||
color: var(--primary, #4c84ff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scale-text {
|
||||
@@ -78,14 +90,40 @@
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
img {
|
||||
.media-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
img,
|
||||
video {
|
||||
display: block;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
object-fit: contain;
|
||||
will-change: transform;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.live-video {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: fill;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
will-change: opacity;
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.live-video.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { ZoomIn, ZoomOut, RotateCw, RotateCcw } from 'lucide-react'
|
||||
import { LivePhotoIcon } from '../components/LivePhotoIcon'
|
||||
import './ImageWindow.scss'
|
||||
|
||||
export default function ImageWindow() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const imagePath = searchParams.get('imagePath')
|
||||
const liveVideoPath = searchParams.get('liveVideoPath')
|
||||
const hasLiveVideo = !!liveVideoPath
|
||||
|
||||
const [isPlayingLive, setIsPlayingLive] = useState(false)
|
||||
const [isVideoVisible, setIsVideoVisible] = useState(false)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const liveCleanupTimerRef = useRef<number | null>(null)
|
||||
|
||||
const [scale, setScale] = useState(1)
|
||||
const [rotation, setRotation] = useState(0)
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 })
|
||||
const [initialScale, setInitialScale] = useState(1)
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
|
||||
// 使用 ref 存储拖动状态,避免闭包问题
|
||||
const dragStateRef = useRef({
|
||||
isDragging: false,
|
||||
@@ -22,11 +30,49 @@ export default function ImageWindow() {
|
||||
startPosY: 0
|
||||
})
|
||||
|
||||
const clearLiveCleanupTimer = useCallback(() => {
|
||||
if (liveCleanupTimerRef.current !== null) {
|
||||
window.clearTimeout(liveCleanupTimerRef.current)
|
||||
liveCleanupTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const stopLivePlayback = useCallback((immediate = false) => {
|
||||
clearLiveCleanupTimer()
|
||||
setIsVideoVisible(false)
|
||||
|
||||
if (immediate) {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause()
|
||||
videoRef.current.currentTime = 0
|
||||
}
|
||||
setIsPlayingLive(false)
|
||||
return
|
||||
}
|
||||
|
||||
liveCleanupTimerRef.current = window.setTimeout(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause()
|
||||
videoRef.current.currentTime = 0
|
||||
}
|
||||
setIsPlayingLive(false)
|
||||
liveCleanupTimerRef.current = null
|
||||
}, 300)
|
||||
}, [clearLiveCleanupTimer])
|
||||
|
||||
const handlePlayLiveVideo = useCallback(() => {
|
||||
if (!liveVideoPath || isPlayingLive) return
|
||||
|
||||
clearLiveCleanupTimer()
|
||||
setIsPlayingLive(true)
|
||||
setIsVideoVisible(false)
|
||||
}, [clearLiveCleanupTimer, liveVideoPath, isPlayingLive])
|
||||
|
||||
const handleZoomIn = () => setScale(prev => Math.min(prev + 0.25, 10))
|
||||
const handleZoomOut = () => setScale(prev => Math.max(prev - 0.25, 0.1))
|
||||
const handleRotate = () => setRotation(prev => (prev + 90) % 360)
|
||||
const handleRotateCcw = () => setRotation(prev => (prev - 90 + 360) % 360)
|
||||
|
||||
|
||||
// 重置视图
|
||||
const handleReset = useCallback(() => {
|
||||
setScale(1)
|
||||
@@ -39,7 +85,7 @@ export default function ImageWindow() {
|
||||
const img = e.currentTarget
|
||||
const naturalWidth = img.naturalWidth
|
||||
const naturalHeight = img.naturalHeight
|
||||
|
||||
|
||||
if (viewportRef.current) {
|
||||
const viewportWidth = viewportRef.current.clientWidth * 0.9
|
||||
const viewportHeight = viewportRef.current.clientHeight * 0.9
|
||||
@@ -51,14 +97,37 @@ export default function ImageWindow() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 视频挂载后再播放,避免点击瞬间 ref 尚未就绪导致丢播
|
||||
useEffect(() => {
|
||||
if (!isPlayingLive || !videoRef.current) return
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
const video = videoRef.current
|
||||
if (!video || !isPlayingLive || !video.paused) return
|
||||
|
||||
video.currentTime = 0
|
||||
void video.play().catch(() => {
|
||||
stopLivePlayback(true)
|
||||
})
|
||||
}, 0)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [isPlayingLive, stopLivePlayback])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearLiveCleanupTimer()
|
||||
}
|
||||
}, [clearLiveCleanupTimer])
|
||||
|
||||
// 使用原生事件监听器处理拖动
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!dragStateRef.current.isDragging) return
|
||||
|
||||
|
||||
const dx = e.clientX - dragStateRef.current.startX
|
||||
const dy = e.clientY - dragStateRef.current.startY
|
||||
|
||||
|
||||
setPosition({
|
||||
x: dragStateRef.current.startPosX + dx,
|
||||
y: dragStateRef.current.startPosY + dy
|
||||
@@ -82,7 +151,7 @@ export default function ImageWindow() {
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return
|
||||
e.preventDefault()
|
||||
|
||||
|
||||
dragStateRef.current = {
|
||||
isDragging: true,
|
||||
startX: e.clientX,
|
||||
@@ -106,15 +175,25 @@ export default function ImageWindow() {
|
||||
// 快捷键支持
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') window.electronAPI.window.close()
|
||||
if (e.key === 'Escape') {
|
||||
if (isPlayingLive) {
|
||||
stopLivePlayback(true)
|
||||
return
|
||||
}
|
||||
window.electronAPI.window.close()
|
||||
}
|
||||
if (e.key === '=' || e.key === '+') handleZoomIn()
|
||||
if (e.key === '-') handleZoomOut()
|
||||
if (e.key === 'r' || e.key === 'R') handleRotate()
|
||||
if (e.key === '0') handleReset()
|
||||
if (e.key === ' ' && hasLiveVideo) {
|
||||
e.preventDefault()
|
||||
handlePlayLiveVideo()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleReset])
|
||||
}, [handleReset, hasLiveVideo, handlePlayLiveVideo, isPlayingLive, stopLivePlayback])
|
||||
|
||||
if (!imagePath) {
|
||||
return (
|
||||
@@ -131,6 +210,20 @@ export default function ImageWindow() {
|
||||
<div className="title-bar">
|
||||
<div className="window-drag-area"></div>
|
||||
<div className="title-bar-controls">
|
||||
{hasLiveVideo && (
|
||||
<>
|
||||
<button
|
||||
onClick={handlePlayLiveVideo}
|
||||
title={isPlayingLive ? '正在播放实况' : '播放实况 (空格)'}
|
||||
className={`live-play-btn ${isPlayingLive ? 'active' : ''}`}
|
||||
disabled={isPlayingLive}
|
||||
>
|
||||
<LivePhotoIcon size={16} />
|
||||
<span style={{ fontSize: 13, marginLeft: 4 }}>Live</span>
|
||||
</button>
|
||||
<div className="divider"></div>
|
||||
</>
|
||||
)}
|
||||
<button onClick={handleZoomOut} title="缩小 (-)"><ZoomOut size={16} /></button>
|
||||
<span className="scale-text">{Math.round(displayScale * 100)}%</span>
|
||||
<button onClick={handleZoomIn} title="放大 (+)"><ZoomIn size={16} /></button>
|
||||
@@ -140,22 +233,38 @@ export default function ImageWindow() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="image-viewport"
|
||||
<div
|
||||
className="image-viewport"
|
||||
ref={viewportRef}
|
||||
onWheel={handleWheel}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<img
|
||||
src={imagePath}
|
||||
alt="Preview"
|
||||
<div
|
||||
className="media-wrapper"
|
||||
style={{
|
||||
transform: `translate(${position.x}px, ${position.y}px) scale(${displayScale}) rotate(${rotation}deg)`
|
||||
}}
|
||||
onLoad={handleImageLoad}
|
||||
draggable={false}
|
||||
/>
|
||||
>
|
||||
<img
|
||||
src={imagePath}
|
||||
alt="Preview"
|
||||
onLoad={handleImageLoad}
|
||||
draggable={false}
|
||||
/>
|
||||
{hasLiveVideo && isPlayingLive && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={liveVideoPath || ''}
|
||||
className={`live-video ${isVideoVisible ? 'visible' : ''}`}
|
||||
autoPlay
|
||||
playsInline
|
||||
preload="auto"
|
||||
onPlaying={() => setIsVideoVisible(true)}
|
||||
onEnded={() => stopLivePlayback(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { NotificationToast, type NotificationData } from '../components/NotificationToast'
|
||||
import { useThemeStore } from '../stores/themeStore'
|
||||
import '../components/NotificationToast.scss'
|
||||
import './NotificationWindow.scss'
|
||||
|
||||
export default function NotificationWindow() {
|
||||
const { currentTheme, themeMode } = useThemeStore()
|
||||
const [notification, setNotification] = useState<NotificationData | null>(null)
|
||||
const [prevNotification, setPrevNotification] = useState<NotificationData | null>(null)
|
||||
|
||||
@@ -17,6 +19,12 @@ export default function NotificationWindow() {
|
||||
|
||||
const notificationRef = useRef<NotificationData | null>(null)
|
||||
|
||||
// 应用主题到通知窗口
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', currentTheme)
|
||||
document.documentElement.setAttribute('data-mode', themeMode)
|
||||
}, [currentTheme, themeMode])
|
||||
|
||||
useEffect(() => {
|
||||
notificationRef.current = notification
|
||||
}, [notification])
|
||||
|
||||
@@ -7,7 +7,7 @@ import { dialog } from '../services/ipc'
|
||||
import * as configService from '../services/config'
|
||||
import {
|
||||
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
||||
RotateCcw, Trash2, Plug, Check, Sun, Moon,
|
||||
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
|
||||
Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic,
|
||||
ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2
|
||||
} from 'lucide-react'
|
||||
@@ -55,6 +55,14 @@ function SettingsPage() {
|
||||
|
||||
const resetChatStore = useChatStore((state) => state.reset)
|
||||
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
|
||||
const [systemDark, setSystemDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handler = (e: MediaQueryListEvent) => setSystemDark(e.matches)
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [])
|
||||
const effectiveMode = themeMode === 'system' ? (systemDark ? 'dark' : 'light') : themeMode
|
||||
const clearAnalyticsStoreCache = useAnalyticsStore((state) => state.clearCache)
|
||||
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('appearance')
|
||||
@@ -82,10 +90,6 @@ function SettingsPage() {
|
||||
const [whisperDownloadProgress, setWhisperDownloadProgress] = useState(0)
|
||||
const [whisperProgressData, setWhisperProgressData] = useState<{ downloaded: number; total: number; speed: number }>({ downloaded: 0, total: 0, speed: 0 })
|
||||
const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; modelPath?: string; tokensPath?: string } | null>(null)
|
||||
const [llamaModelStatus, setLlamaModelStatus] = useState<{ exists: boolean; path?: string; size?: number } | null>(null)
|
||||
const [isLlamaDownloading, setIsLlamaDownloading] = useState(false)
|
||||
const [llamaDownloadProgress, setLlamaDownloadProgress] = useState(0)
|
||||
const [llamaProgressData, setLlamaProgressData] = useState<{ downloaded: number; total: number; speed: number }>({ downloaded: 0, total: 0, speed: 0 })
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
@@ -142,6 +146,11 @@ function SettingsPage() {
|
||||
const [helloAvailable, setHelloAvailable] = useState(false)
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [oldPassword, setOldPassword] = useState('')
|
||||
const [helloPassword, setHelloPassword] = useState('')
|
||||
const [disableLockPassword, setDisableLockPassword] = useState('')
|
||||
const [showDisableLockInput, setShowDisableLockInput] = useState(false)
|
||||
const [isLockMode, setIsLockMode] = useState(false)
|
||||
const [isSettingHello, setIsSettingHello] = useState(false)
|
||||
|
||||
// HTTP API 设置 state
|
||||
@@ -180,14 +189,6 @@ function SettingsPage() {
|
||||
checkApiStatus()
|
||||
}, [])
|
||||
|
||||
async function sha256(message: string) {
|
||||
const msgBuffer = new TextEncoder().encode(message)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
return hashHex
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig()
|
||||
loadAppVersion()
|
||||
@@ -275,10 +276,12 @@ function SettingsPage() {
|
||||
const savedNotificationFilterMode = await configService.getNotificationFilterMode()
|
||||
const savedNotificationFilterList = await configService.getNotificationFilterList()
|
||||
|
||||
const savedAuthEnabled = await configService.getAuthEnabled()
|
||||
const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled()
|
||||
const savedAuthUseHello = await configService.getAuthUseHello()
|
||||
const savedIsLockMode = await window.electronAPI.auth.isLockMode()
|
||||
setAuthEnabled(savedAuthEnabled)
|
||||
setAuthUseHello(savedAuthUseHello)
|
||||
setIsLockMode(savedIsLockMode)
|
||||
|
||||
if (savedPath) setDbPath(savedPath)
|
||||
if (savedWxid) setWxid(savedWxid)
|
||||
@@ -328,8 +331,7 @@ function SettingsPage() {
|
||||
|
||||
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
|
||||
|
||||
// Load Llama status after config
|
||||
void checkLlamaModelStatus()
|
||||
|
||||
} catch (e: any) {
|
||||
console.error('加载配置失败:', e)
|
||||
}
|
||||
@@ -645,7 +647,6 @@ function SettingsPage() {
|
||||
setWhisperModelDir(dir)
|
||||
await configService.setWhisperModelDir(dir)
|
||||
showMessage('已选择 Whisper 模型目录', true)
|
||||
await checkLlamaModelStatus()
|
||||
}
|
||||
} catch (e: any) {
|
||||
showMessage('选择目录失败', false)
|
||||
@@ -681,68 +682,6 @@ function SettingsPage() {
|
||||
const handleResetWhisperModelDir = async () => {
|
||||
setWhisperModelDir('')
|
||||
await configService.setWhisperModelDir('')
|
||||
await checkLlamaModelStatus()
|
||||
}
|
||||
|
||||
const checkLlamaModelStatus = async () => {
|
||||
try {
|
||||
// @ts-ignore
|
||||
const modelsPath = await window.electronAPI.llama?.getModelsPath()
|
||||
if (!modelsPath) return
|
||||
const modelName = "Qwen3-4B-Q4_K_M.gguf" // Hardcoded preset for now
|
||||
const fullPath = `${modelsPath}\\${modelName}`
|
||||
// @ts-ignore
|
||||
const status = await window.electronAPI.llama?.getModelStatus(fullPath)
|
||||
if (status) {
|
||||
setLlamaModelStatus({
|
||||
exists: status.exists,
|
||||
path: status.path,
|
||||
size: status.size
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Check llama model status failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleLlamaProgress = (payload: { downloaded: number; total: number; speed: number }) => {
|
||||
setLlamaProgressData(payload)
|
||||
if (payload.total > 0) {
|
||||
setLlamaDownloadProgress((payload.downloaded / payload.total) * 100)
|
||||
}
|
||||
}
|
||||
// @ts-ignore
|
||||
const removeListener = window.electronAPI.llama?.onDownloadProgress(handleLlamaProgress)
|
||||
return () => {
|
||||
if (typeof removeListener === 'function') removeListener()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDownloadLlamaModel = async () => {
|
||||
if (isLlamaDownloading) return
|
||||
setIsLlamaDownloading(true)
|
||||
setLlamaDownloadProgress(0)
|
||||
try {
|
||||
const modelUrl = "https://www.modelscope.cn/models/Qwen/Qwen3-4B-GGUF/resolve/master/Qwen3-4B-Q4_K_M.gguf"
|
||||
// @ts-ignore
|
||||
const modelsPath = await window.electronAPI.llama?.getModelsPath()
|
||||
const modelName = "Qwen3-4B-Q4_K_M.gguf"
|
||||
const fullPath = `${modelsPath}\\${modelName}`
|
||||
|
||||
// @ts-ignore
|
||||
const result = await window.electronAPI.llama?.downloadModel(modelUrl, fullPath)
|
||||
if (result?.success) {
|
||||
showMessage('Qwen3 模型下载完成', true)
|
||||
await checkLlamaModelStatus()
|
||||
} else {
|
||||
showMessage(`模型下载失败: ${result?.error || '未知错误'}`, false)
|
||||
}
|
||||
} catch (e: any) {
|
||||
showMessage(`模型下载失败: ${e}`, false)
|
||||
} finally {
|
||||
setIsLlamaDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAutoGetDbKey = async () => {
|
||||
@@ -993,11 +932,14 @@ function SettingsPage() {
|
||||
<button className={`mode-btn ${themeMode === 'dark' ? 'active' : ''}`} onClick={() => setThemeMode('dark')}>
|
||||
<Moon size={16} /> 深色
|
||||
</button>
|
||||
<button className={`mode-btn ${themeMode === 'system' ? 'active' : ''}`} onClick={() => setThemeMode('system')}>
|
||||
<Monitor size={16} /> 跟随系统
|
||||
</button>
|
||||
</div>
|
||||
<div className="theme-grid">
|
||||
{themes.map((theme) => (
|
||||
<div key={theme.id} className={`theme-card ${currentTheme === theme.id ? 'active' : ''}`} onClick={() => setTheme(theme.id)}>
|
||||
<div className="theme-preview" style={{ background: themeMode === 'dark' ? 'linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%)' : `linear-gradient(135deg, ${theme.bgColor} 0%, ${theme.bgColor}dd 100%)` }}>
|
||||
<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-accent" style={{ background: theme.primaryColor }} />
|
||||
</div>
|
||||
<div className="theme-info">
|
||||
@@ -1441,7 +1383,7 @@ function SettingsPage() {
|
||||
<div className="tab-content">
|
||||
<div className="form-group">
|
||||
<label>模型管理</label>
|
||||
<span className="form-hint">管理语音识别和 AI 对话模型</span>
|
||||
<span className="form-hint">管理语音识别模型</span>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
@@ -1511,50 +1453,6 @@ function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>AI 对话模型 (Llama)</label>
|
||||
<span className="form-hint">用于 AI 助手对话功能</span>
|
||||
<div className="setting-control vertical has-border">
|
||||
<div className="model-status-card">
|
||||
<div className="model-info">
|
||||
<div className="model-name">Qwen3 4B (Preset) (~2.6GB)</div>
|
||||
<div className="model-path">
|
||||
{llamaModelStatus?.exists ? (
|
||||
<span className="status-indicator success"><Check size={14} /> 已安装</span>
|
||||
) : (
|
||||
<span className="status-indicator warning">未安装</span>
|
||||
)}
|
||||
{llamaModelStatus?.path && <div className="path-text" title={llamaModelStatus.path}>{llamaModelStatus.path}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="model-actions">
|
||||
{!llamaModelStatus?.exists && !isLlamaDownloading && (
|
||||
<button
|
||||
className="btn-download"
|
||||
onClick={handleDownloadLlamaModel}
|
||||
>
|
||||
<Download size={16} /> 下载模型
|
||||
</button>
|
||||
)}
|
||||
{isLlamaDownloading && (
|
||||
<div className="download-status">
|
||||
<div className="status-header">
|
||||
<span className="percent">{Math.floor(llamaDownloadProgress)}%</span>
|
||||
<span className="metrics">
|
||||
{formatBytes(llamaProgressData.downloaded)} / {formatBytes(llamaProgressData.total)}
|
||||
<span className="speed">({formatBytes(llamaProgressData.speed)}/s)</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="progress-bar-mini">
|
||||
<div className="fill" style={{ width: `${llamaDownloadProgress}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>自动转文字</label>
|
||||
<span className="form-hint">收到语音消息时自动转换为文字</span>
|
||||
@@ -2032,6 +1930,10 @@ function SettingsPage() {
|
||||
)
|
||||
|
||||
const handleSetupHello = async () => {
|
||||
if (!helloPassword) {
|
||||
showMessage('请输入当前密码以开启 Hello', false)
|
||||
return
|
||||
}
|
||||
setIsSettingHello(true)
|
||||
try {
|
||||
const challenge = new Uint8Array(32)
|
||||
@@ -2049,8 +1951,10 @@ function SettingsPage() {
|
||||
})
|
||||
|
||||
if (credential) {
|
||||
// 存储密码作为 Hello Secret,以便 Hello 解锁时能派生密钥
|
||||
await window.electronAPI.auth.setHelloSecret(helloPassword)
|
||||
setAuthUseHello(true)
|
||||
await configService.setAuthUseHello(true)
|
||||
setHelloPassword('')
|
||||
showMessage('Windows Hello 设置成功', true)
|
||||
}
|
||||
} catch (e: any) {
|
||||
@@ -2068,18 +1972,40 @@ function SettingsPage() {
|
||||
return
|
||||
}
|
||||
|
||||
// 简单的保存逻辑,实际上应该先验证旧密码,但为了简化流程,这里直接允许覆盖
|
||||
// 因为能进入设置页面说明已经解锁了
|
||||
try {
|
||||
const hash = await sha256(newPassword)
|
||||
await configService.setAuthPassword(hash)
|
||||
await configService.setAuthEnabled(true)
|
||||
setAuthEnabled(true)
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
showMessage('密码已更新', true)
|
||||
const lockMode = await window.electronAPI.auth.isLockMode()
|
||||
|
||||
if (authEnabled && lockMode) {
|
||||
// 已开启应用锁且已是 lock: 模式 → 修改密码
|
||||
if (!oldPassword) {
|
||||
showMessage('请输入旧密码', false)
|
||||
return
|
||||
}
|
||||
const result = await window.electronAPI.auth.changePassword(oldPassword, newPassword)
|
||||
if (result.success) {
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
setOldPassword('')
|
||||
showMessage('密码已更新', true)
|
||||
} else {
|
||||
showMessage(result.error || '密码更新失败', false)
|
||||
}
|
||||
} else {
|
||||
// 未开启应用锁,或旧版 safe: 模式 → 开启/升级为 lock: 模式
|
||||
const result = await window.electronAPI.auth.enableLock(newPassword)
|
||||
if (result.success) {
|
||||
setAuthEnabled(true)
|
||||
setIsLockMode(true)
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
setOldPassword('')
|
||||
showMessage('应用锁已开启', true)
|
||||
} else {
|
||||
showMessage(result.error || '开启失败', false)
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
showMessage('密码更新失败', false)
|
||||
showMessage('操作失败', false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2138,31 +2064,73 @@ function SettingsPage() {
|
||||
<div className="form-group">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<label>启用应用锁</label>
|
||||
<span className="form-hint">每次启动应用时需要验证密码</span>
|
||||
<label>应用锁状态</label>
|
||||
<span className="form-hint">{
|
||||
isLockMode ? '已开启' :
|
||||
authEnabled ? '旧版模式 — 请重新设置密码以升级为新模式提高安全性' :
|
||||
'未开启 — 请设置密码以开启'
|
||||
}</span>
|
||||
</div>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={authEnabled}
|
||||
onChange={async (e) => {
|
||||
const enabled = e.target.checked
|
||||
setAuthEnabled(enabled)
|
||||
await configService.setAuthEnabled(enabled)
|
||||
}}
|
||||
/>
|
||||
<span className="switch-slider" />
|
||||
</label>
|
||||
{authEnabled && !showDisableLockInput && (
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => setShowDisableLockInput(true)}
|
||||
>
|
||||
关闭应用锁
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showDisableLockInput && (
|
||||
<div style={{ marginTop: 10, display: 'flex', gap: 10 }}>
|
||||
<input
|
||||
type="password"
|
||||
className="field-input"
|
||||
placeholder="输入当前密码以关闭"
|
||||
value={disableLockPassword}
|
||||
onChange={e => setDisableLockPassword(e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={!disableLockPassword}
|
||||
onClick={async () => {
|
||||
const result = await window.electronAPI.auth.disableLock(disableLockPassword)
|
||||
if (result.success) {
|
||||
setAuthEnabled(false)
|
||||
setAuthUseHello(false)
|
||||
setIsLockMode(false)
|
||||
setShowDisableLockInput(false)
|
||||
setDisableLockPassword('')
|
||||
showMessage('应用锁已关闭', true)
|
||||
} else {
|
||||
showMessage(result.error || '关闭失败', false)
|
||||
}
|
||||
}}
|
||||
>确认</button>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => { setShowDisableLockInput(false); setDisableLockPassword('') }}
|
||||
>取消</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
<div className="form-group">
|
||||
<label>重置密码</label>
|
||||
<span className="form-hint">设置新的启动密码</span>
|
||||
<label>{isLockMode ? '修改密码' : '设置密码并开启应用锁'}</label>
|
||||
<span className="form-hint">{isLockMode ? '修改应用锁密码(需要旧密码验证)' : '设置密码后将自动开启应用锁'}</span>
|
||||
|
||||
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{isLockMode && (
|
||||
<input
|
||||
type="password"
|
||||
className="field-input"
|
||||
placeholder="旧密码"
|
||||
value={oldPassword}
|
||||
onChange={e => setOldPassword(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
type="password"
|
||||
className="field-input"
|
||||
@@ -2179,7 +2147,9 @@ function SettingsPage() {
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={handleUpdatePassword} disabled={!newPassword}>更新</button>
|
||||
<button className="btn btn-primary" onClick={handleUpdatePassword} disabled={!newPassword}>
|
||||
{isLockMode ? '更新' : '开启'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2191,23 +2161,39 @@ function SettingsPage() {
|
||||
<div>
|
||||
<label>Windows Hello</label>
|
||||
<span className="form-hint">使用面容、指纹快速解锁</span>
|
||||
{!helloAvailable && <div className="form-hint warning" style={{ color: '#ff4d4f' }}> 当前设备不支持 Windows Hello</div>}
|
||||
{!authEnabled && <div className="form-hint warning" style={{ color: '#ff4d4f' }}>请先开启应用锁</div>}
|
||||
{!helloAvailable && authEnabled && <div className="form-hint warning" style={{ color: '#ff4d4f' }}>当前设备不支持 Windows Hello</div>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{authUseHello ? (
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setAuthUseHello(false)}>关闭</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={async () => {
|
||||
await window.electronAPI.auth.clearHelloSecret()
|
||||
setAuthUseHello(false)
|
||||
showMessage('Windows Hello 已关闭', true)
|
||||
}}>关闭</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={handleSetupHello}
|
||||
disabled={!helloAvailable || isSettingHello}
|
||||
disabled={!helloAvailable || isSettingHello || !authEnabled || !helloPassword}
|
||||
>
|
||||
{isSettingHello ? '设置中...' : '开启与设置'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!authUseHello && authEnabled && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<input
|
||||
type="password"
|
||||
className="field-input"
|
||||
placeholder="输入当前密码以开启 Hello"
|
||||
value={helloPassword}
|
||||
onChange={e => setHelloPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -24,8 +24,6 @@
|
||||
.sns-main-viewport {
|
||||
flex: 1;
|
||||
overflow-y: scroll;
|
||||
/* Always show scrollbar track for stability */
|
||||
scroll-behavior: smooth;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -105,11 +103,29 @@
|
||||
gap: 16px;
|
||||
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
&.post-deleted {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.post-deleted-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: rgba(255, 77, 79, 0.1);
|
||||
color: #ff4d4f;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.post-avatar-col {
|
||||
@@ -131,6 +147,8 @@
|
||||
.post-author-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
.author-name {
|
||||
font-size: 15px;
|
||||
@@ -147,6 +165,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.post-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.debug-btn {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
@@ -313,12 +338,15 @@
|
||||
width: fit-content;
|
||||
height: auto;
|
||||
max-width: 300px;
|
||||
/* Max width constraint */
|
||||
max-height: 480px;
|
||||
/* Increased max height a bit */
|
||||
aspect-ratio: auto;
|
||||
border-radius: var(--sns-border-radius-md);
|
||||
|
||||
&.deleted-media {
|
||||
width: 200px;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
img,
|
||||
video {
|
||||
max-width: 100%;
|
||||
@@ -327,7 +355,6 @@
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
/* Remove baseline space */
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
@@ -387,7 +414,8 @@
|
||||
|
||||
&.live {
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
left: auto;
|
||||
transform: none;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
@@ -444,6 +472,22 @@
|
||||
&:hover .media-download-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.deleted-media {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
|
||||
.deleted-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 6px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -653,6 +697,17 @@
|
||||
top: 8px;
|
||||
color: var(--text-tertiary);
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.clear-icon {
|
||||
position: absolute;
|
||||
right: 28px;
|
||||
top: 8px;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1268,6 +1323,18 @@
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
@@ -1343,6 +1410,70 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.year-month-picker {
|
||||
padding: 4px 0;
|
||||
|
||||
.year-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.year-label {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.month-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
|
||||
.month-btn {
|
||||
padding: 10px 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quick-options {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, 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 { ImagePreview } from '../components/ImagePreview'
|
||||
import JumpToDateDialog from '../components/JumpToDateDialog'
|
||||
import './SnsPage.scss'
|
||||
import { SnsPost } from '../types/sns'
|
||||
@@ -11,6 +10,7 @@ interface Contact {
|
||||
username: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
type?: 'friend' | 'former_friend' | 'sns_only'
|
||||
}
|
||||
|
||||
export default function SnsPage() {
|
||||
@@ -31,7 +31,6 @@ export default function SnsPage() {
|
||||
|
||||
// UI states
|
||||
const [showJumpDialog, setShowJumpDialog] = useState(false)
|
||||
const [previewImage, setPreviewImage] = useState<{ src: string, isVideo?: boolean, liveVideoPath?: string } | null>(null)
|
||||
const [debugPost, setDebugPost] = useState<SnsPost | null>(null)
|
||||
|
||||
// 导出相关状态
|
||||
@@ -45,28 +44,29 @@ export default function SnsPage() {
|
||||
const [exportResult, setExportResult] = useState<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string } | null>(null)
|
||||
const [refreshSpin, setRefreshSpin] = useState(false)
|
||||
const [calendarPicker, setCalendarPicker] = useState<{ field: 'start' | 'end'; month: Date } | null>(null)
|
||||
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
|
||||
|
||||
const postsContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [hasNewer, setHasNewer] = useState(false)
|
||||
const [loadingNewer, setLoadingNewer] = useState(false)
|
||||
const postsRef = useRef<SnsPost[]>([])
|
||||
const scrollAdjustmentRef = useRef<number>(0)
|
||||
const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
|
||||
|
||||
// Sync posts ref
|
||||
useEffect(() => {
|
||||
postsRef.current = posts
|
||||
}, [posts])
|
||||
|
||||
// Maintain scroll position when loading newer posts
|
||||
useEffect(() => {
|
||||
if (scrollAdjustmentRef.current !== 0 && postsContainerRef.current) {
|
||||
// 在 DOM 更新后、浏览器绘制前同步调整滚动位置,防止向上加载时页面跳动
|
||||
useLayoutEffect(() => {
|
||||
const snapshot = scrollAdjustmentRef.current;
|
||||
if (snapshot && postsContainerRef.current) {
|
||||
const container = postsContainerRef.current;
|
||||
const newHeight = container.scrollHeight;
|
||||
const diff = newHeight - scrollAdjustmentRef.current;
|
||||
if (diff > 0) {
|
||||
container.scrollTop += diff;
|
||||
const addedHeight = container.scrollHeight - snapshot.scrollHeight;
|
||||
if (addedHeight > 0) {
|
||||
container.scrollTop = snapshot.scrollTop + addedHeight;
|
||||
}
|
||||
scrollAdjustmentRef.current = 0;
|
||||
scrollAdjustmentRef.current = null;
|
||||
}
|
||||
}, [posts])
|
||||
|
||||
@@ -104,14 +104,17 @@ export default function SnsPage() {
|
||||
|
||||
if (result.success && result.timeline && result.timeline.length > 0) {
|
||||
if (postsContainerRef.current) {
|
||||
scrollAdjustmentRef.current = postsContainerRef.current.scrollHeight;
|
||||
scrollAdjustmentRef.current = {
|
||||
scrollHeight: postsContainerRef.current.scrollHeight,
|
||||
scrollTop: postsContainerRef.current.scrollTop
|
||||
};
|
||||
}
|
||||
|
||||
const existingIds = new Set(currentPosts.map((p: SnsPost) => p.id));
|
||||
const uniqueNewer = result.timeline.filter((p: SnsPost) => !existingIds.has(p.id));
|
||||
|
||||
if (uniqueNewer.length > 0) {
|
||||
setPosts(prev => [...uniqueNewer, ...prev]);
|
||||
setPosts(prev => [...uniqueNewer, ...prev].sort((a, b) => b.createTime - a.createTime));
|
||||
}
|
||||
setHasNewer(result.timeline.length >= limit);
|
||||
} else {
|
||||
@@ -157,7 +160,7 @@ export default function SnsPage() {
|
||||
}
|
||||
} else {
|
||||
if (result.timeline.length > 0) {
|
||||
setPosts(prev => [...prev, ...result.timeline!])
|
||||
setPosts(prev => [...prev, ...result.timeline!].sort((a, b) => b.createTime - a.createTime))
|
||||
}
|
||||
if (result.timeline.length < limit) {
|
||||
setHasMore(false)
|
||||
@@ -173,45 +176,59 @@ export default function SnsPage() {
|
||||
}
|
||||
}, [selectedUsernames, searchKeyword, jumpTargetDate])
|
||||
|
||||
// Load Contacts
|
||||
// Load Contacts(合并好友+曾经好友+朋友圈发布者,enrichSessionsContactInfo 补充头像)
|
||||
const loadContacts = useCallback(async () => {
|
||||
setContactsLoading(true)
|
||||
try {
|
||||
const result = await window.electronAPI.chat.getSessions()
|
||||
if (result.success && result.sessions) {
|
||||
const systemAccounts = ['filehelper', 'fmessage', 'newsapp', 'weixin', 'qqmail', 'tmessage', 'floatbottle', 'medianote', 'brandsessionholder'];
|
||||
const initialContacts = result.sessions
|
||||
.filter((s: any) => {
|
||||
if (!s.username) return false;
|
||||
const u = s.username.toLowerCase();
|
||||
if (u.includes('@chatroom') || u.endsWith('@chatroom') || u.endsWith('@openim')) return false;
|
||||
if (u.startsWith('gh_')) return false;
|
||||
if (systemAccounts.includes(u) || u.includes('helper') || u.includes('sessionholder')) return false;
|
||||
return true;
|
||||
})
|
||||
.map((s: any) => ({
|
||||
username: s.username,
|
||||
displayName: s.displayName || s.username,
|
||||
avatarUrl: s.avatarUrl
|
||||
}))
|
||||
setContacts(initialContacts)
|
||||
// 并行获取联系人列表和朋友圈发布者列表
|
||||
const [contactsResult, snsResult] = await Promise.all([
|
||||
window.electronAPI.chat.getContacts(),
|
||||
window.electronAPI.sns.getSnsUsernames()
|
||||
])
|
||||
|
||||
const usernames = initialContacts.map((c: { username: string }) => c.username)
|
||||
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
|
||||
if (enriched.success && enriched.contacts) {
|
||||
setContacts(prev => prev.map(c => {
|
||||
const extra = enriched.contacts![c.username]
|
||||
if (extra) {
|
||||
return {
|
||||
...c,
|
||||
displayName: extra.displayName || c.displayName,
|
||||
avatarUrl: extra.avatarUrl || c.avatarUrl
|
||||
}
|
||||
}
|
||||
return c
|
||||
}))
|
||||
// 以联系人为基础,按 username 去重
|
||||
const contactMap = new Map<string, Contact>()
|
||||
|
||||
// 好友和曾经的好友
|
||||
if (contactsResult.success && contactsResult.contacts) {
|
||||
for (const c of contactsResult.contacts) {
|
||||
if (c.type === 'friend' || c.type === 'former_friend') {
|
||||
contactMap.set(c.username, {
|
||||
username: c.username,
|
||||
displayName: c.displayName,
|
||||
avatarUrl: c.avatarUrl,
|
||||
type: c.type === 'former_friend' ? 'former_friend' : 'friend'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 朋友圈发布者(补充不在联系人列表中的用户)
|
||||
if (snsResult.success && snsResult.usernames) {
|
||||
for (const u of snsResult.usernames) {
|
||||
if (!contactMap.has(u)) {
|
||||
contactMap.set(u, { username: u, displayName: u, type: 'sns_only' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allUsernames = Array.from(contactMap.keys())
|
||||
|
||||
// 用 enrichSessionsContactInfo 统一补充头像和显示名
|
||||
if (allUsernames.length > 0) {
|
||||
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(allUsernames)
|
||||
if (enriched.success && enriched.contacts) {
|
||||
for (const [username, extra] of Object.entries(enriched.contacts) as [string, { displayName?: string; avatarUrl?: string }][]) {
|
||||
const c = contactMap.get(username)
|
||||
if (c) {
|
||||
c.displayName = extra.displayName || c.displayName
|
||||
c.avatarUrl = extra.avatarUrl || c.avatarUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setContacts(Array.from(contactMap.values()))
|
||||
} catch (error) {
|
||||
console.error('Failed to load contacts:', error)
|
||||
} finally {
|
||||
@@ -313,7 +330,13 @@ export default function SnsPage() {
|
||||
<SnsPostItem
|
||||
key={post.id}
|
||||
post={post}
|
||||
onPreview={(src, isVideo, liveVideoPath) => setPreviewImage({ src, isVideo, liveVideoPath })}
|
||||
onPreview={(src, isVideo, liveVideoPath) => {
|
||||
if (isVideo) {
|
||||
void window.electronAPI.window.openVideoPlayerWindow(src)
|
||||
} else {
|
||||
void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined)
|
||||
}
|
||||
}}
|
||||
onDebug={(p) => setDebugPost(p)}
|
||||
/>
|
||||
))}
|
||||
@@ -336,7 +359,12 @@ export default function SnsPage() {
|
||||
)}
|
||||
|
||||
{!hasMore && posts.length > 0 && (
|
||||
<div className="status-indicator no-more">已经到底啦</div>
|
||||
<div className="status-indicator no-more">{
|
||||
selectedUsernames.length === 1 &&
|
||||
contacts.find(c => c.username === selectedUsernames[0])?.type === 'former_friend'
|
||||
? '在时间的长河里刻舟求剑'
|
||||
: '或许过往已无可溯洄,但好在还有可以与你相遇的明天'
|
||||
}</div>
|
||||
)}
|
||||
|
||||
{!loading && posts.length === 0 && (
|
||||
@@ -370,15 +398,6 @@ export default function SnsPage() {
|
||||
/>
|
||||
|
||||
{/* Dialogs and Overlays */}
|
||||
{previewImage && (
|
||||
<ImagePreview
|
||||
src={previewImage.src}
|
||||
isVideo={previewImage.isVideo}
|
||||
liveVideoPath={previewImage.liveVideoPath}
|
||||
onClose={() => setPreviewImage(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<JumpToDateDialog
|
||||
isOpen={showJumpDialog}
|
||||
onClose={() => setShowJumpDialog(false)}
|
||||
@@ -655,14 +674,14 @@ export default function SnsPage() {
|
||||
|
||||
{/* 日期选择弹窗 */}
|
||||
{calendarPicker && (
|
||||
<div className="calendar-overlay" onClick={() => setCalendarPicker(null)}>
|
||||
<div className="calendar-overlay" onClick={() => { setCalendarPicker(null); setShowYearMonthPicker(false) }}>
|
||||
<div className="calendar-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="calendar-header">
|
||||
<div className="title-area">
|
||||
<Calendar size={18} />
|
||||
<h3>选择{calendarPicker.field === 'start' ? '开始' : '结束'}日期</h3>
|
||||
</div>
|
||||
<button className="close-btn" onClick={() => setCalendarPicker(null)}>
|
||||
<button className="close-btn" onClick={() => { setCalendarPicker(null); setShowYearMonthPicker(false) }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -671,13 +690,39 @@ export default function SnsPage() {
|
||||
<button className="nav-btn" onClick={() => setCalendarPicker(prev => prev ? { ...prev, month: new Date(prev.month.getFullYear(), prev.month.getMonth() - 1, 1) } : null)}>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<span className="current-month">
|
||||
<span className="current-month clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
|
||||
{calendarPicker.month.getFullYear()}年{calendarPicker.month.getMonth() + 1}月
|
||||
</span>
|
||||
<button className="nav-btn" onClick={() => setCalendarPicker(prev => prev ? { ...prev, month: new Date(prev.month.getFullYear(), prev.month.getMonth() + 1, 1) } : null)}>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{showYearMonthPicker ? (
|
||||
<div className="year-month-picker">
|
||||
<div className="year-selector">
|
||||
<button className="nav-btn" onClick={() => setCalendarPicker(prev => prev ? { ...prev, month: new Date(prev.month.getFullYear() - 1, prev.month.getMonth(), 1) } : null)}>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span className="year-label">{calendarPicker.month.getFullYear()}年</span>
|
||||
<button className="nav-btn" onClick={() => setCalendarPicker(prev => prev ? { ...prev, month: new Date(prev.month.getFullYear() + 1, prev.month.getMonth(), 1) } : null)}>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="month-grid">
|
||||
{['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`month-btn ${i === calendarPicker.month.getMonth() ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setCalendarPicker(prev => prev ? { ...prev, month: new Date(prev.month.getFullYear(), i, 1) } : null)
|
||||
setShowYearMonthPicker(false)
|
||||
}}
|
||||
>{name}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="calendar-weekdays">
|
||||
{['日', '一', '二', '三', '四', '五', '六'].map(d => <div key={d} className="weekday">{d}</div>)}
|
||||
</div>
|
||||
@@ -710,6 +755,8 @@ export default function SnsPage() {
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="quick-options">
|
||||
<button onClick={() => {
|
||||
@@ -733,7 +780,7 @@ export default function SnsPage() {
|
||||
}}>{calendarPicker.field === 'start' ? '三个月前' : '一个月前'}</button>
|
||||
</div>
|
||||
<div className="dialog-footer">
|
||||
<button className="cancel-btn" onClick={() => setCalendarPicker(null)}>取消</button>
|
||||
<button className="cancel-btn" onClick={() => { setCalendarPicker(null); setShowYearMonthPicker(false) }}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
|
||||
export interface ModelInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
downloadUrl?: string; // If it's a known preset
|
||||
size?: number;
|
||||
downloaded: boolean;
|
||||
}
|
||||
|
||||
export const PRESET_MODELS: ModelInfo[] = [
|
||||
{
|
||||
name: "Qwen3 4B (Preset)",
|
||||
path: "Qwen3-4B-Q4_K_M.gguf",
|
||||
downloadUrl: "https://www.modelscope.cn/models/Qwen/Qwen3-4B-GGUF/resolve/master/Qwen3-4B-Q4_K_M.gguf",
|
||||
downloaded: false
|
||||
}
|
||||
];
|
||||
|
||||
class EngineService {
|
||||
private onTokenCallback: ((token: string) => void) | null = null;
|
||||
private onProgressCallback: ((percent: number) => void) | null = null;
|
||||
private _removeTokenListener: (() => void) | null = null;
|
||||
private _removeProgressListener: (() => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
// Initialize listeners
|
||||
this._removeTokenListener = window.electronAPI.llama.onToken((token: string) => {
|
||||
if (this.onTokenCallback) {
|
||||
this.onTokenCallback(token);
|
||||
}
|
||||
});
|
||||
|
||||
this._removeProgressListener = window.electronAPI.llama.onDownloadProgress((percent: number) => {
|
||||
if (this.onProgressCallback) {
|
||||
this.onProgressCallback(percent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async checkModelExists(filename: string): Promise<boolean> {
|
||||
const modelsPath = await window.electronAPI.llama.getModelsPath();
|
||||
const fullPath = `${modelsPath}\\${filename}`; // Windows path separator
|
||||
// We might need to handle path separator properly or let main process handle it
|
||||
// Updated preload to take full path or handling in main?
|
||||
// Let's rely on main process exposing join or just checking relative to models dir if implemented
|
||||
// Actually main process `checkFileExists` takes a path.
|
||||
// Let's assume we construct path here or Main helps.
|
||||
// Better: getModelsPath returns the directory.
|
||||
return await window.electronAPI.llama.checkFileExists(fullPath);
|
||||
}
|
||||
|
||||
public async getModelsPath(): Promise<string> {
|
||||
return await window.electronAPI.llama.getModelsPath();
|
||||
}
|
||||
|
||||
public async loadModel(filename: string) {
|
||||
const modelsPath = await this.getModelsPath();
|
||||
const fullPath = `${modelsPath}\\${filename}`;
|
||||
console.log("Loading model:", fullPath);
|
||||
return await window.electronAPI.llama.loadModel(fullPath);
|
||||
}
|
||||
|
||||
public async createSession(systemPrompt?: string) {
|
||||
return await window.electronAPI.llama.createSession(systemPrompt);
|
||||
}
|
||||
|
||||
public async chat(message: string, onToken: (token: string) => void, options?: { thinking?: boolean }) {
|
||||
this.onTokenCallback = onToken;
|
||||
return await window.electronAPI.llama.chat(message, options);
|
||||
}
|
||||
|
||||
public async downloadModel(url: string, filename: string, onProgress: (percent: number) => void) {
|
||||
const modelsPath = await this.getModelsPath();
|
||||
const fullPath = `${modelsPath}\\${filename}`;
|
||||
this.onProgressCallback = onProgress;
|
||||
return await window.electronAPI.llama.downloadModel(url, fullPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除当前的回调函数引用
|
||||
* 用于避免内存泄漏
|
||||
*/
|
||||
public clearCallbacks() {
|
||||
this.onTokenCallback = null;
|
||||
this.onProgressCallback = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放所有资源
|
||||
* 包括事件监听器和回调引用
|
||||
*/
|
||||
public dispose() {
|
||||
// 清除回调
|
||||
this.clearCallbacks();
|
||||
|
||||
// 移除事件监听器
|
||||
if (this._removeTokenListener) {
|
||||
this._removeTokenListener();
|
||||
this._removeTokenListener = null;
|
||||
}
|
||||
if (this._removeProgressListener) {
|
||||
this._removeProgressListener();
|
||||
this._removeProgressListener = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const engineService = new EngineService();
|
||||
@@ -118,13 +118,13 @@ export async function setWxidConfig(wxid: string, configValue: WxidConfig): Prom
|
||||
}
|
||||
|
||||
// 获取主题
|
||||
export async function getTheme(): Promise<'light' | 'dark'> {
|
||||
export async function getTheme(): Promise<'light' | 'dark' | 'system'> {
|
||||
const value = await config.get(CONFIG_KEYS.THEME)
|
||||
return (value as 'light' | 'dark') || 'light'
|
||||
return (value as 'light' | 'dark' | 'system') || 'light'
|
||||
}
|
||||
|
||||
// 设置主题
|
||||
export async function setTheme(theme: 'light' | 'dark'): Promise<void> {
|
||||
export async function setTheme(theme: 'light' | 'dark' | 'system'): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.THEME, theme)
|
||||
}
|
||||
|
||||
|
||||
64
src/stores/batchImageDecryptStore.ts
Normal file
64
src/stores/batchImageDecryptStore.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
export interface BatchImageDecryptState {
|
||||
isBatchDecrypting: boolean
|
||||
progress: { current: number; total: number }
|
||||
showToast: boolean
|
||||
showResultToast: boolean
|
||||
result: { success: number; fail: number }
|
||||
startTime: number
|
||||
sessionName: string
|
||||
|
||||
startDecrypt: (total: number, sessionName: string) => void
|
||||
updateProgress: (current: number, total: number) => void
|
||||
finishDecrypt: (success: number, fail: number) => void
|
||||
setShowToast: (show: boolean) => void
|
||||
setShowResultToast: (show: boolean) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set) => ({
|
||||
isBatchDecrypting: false,
|
||||
progress: { current: 0, total: 0 },
|
||||
showToast: false,
|
||||
showResultToast: false,
|
||||
result: { success: 0, fail: 0 },
|
||||
startTime: 0,
|
||||
sessionName: '',
|
||||
|
||||
startDecrypt: (total, sessionName) => set({
|
||||
isBatchDecrypting: true,
|
||||
progress: { current: 0, total },
|
||||
showToast: true,
|
||||
showResultToast: false,
|
||||
result: { success: 0, fail: 0 },
|
||||
startTime: Date.now(),
|
||||
sessionName
|
||||
}),
|
||||
|
||||
updateProgress: (current, total) => set({
|
||||
progress: { current, total }
|
||||
}),
|
||||
|
||||
finishDecrypt: (success, fail) => set({
|
||||
isBatchDecrypting: false,
|
||||
showToast: false,
|
||||
showResultToast: true,
|
||||
result: { success, fail },
|
||||
startTime: 0
|
||||
}),
|
||||
|
||||
setShowToast: (show) => set({ showToast: show }),
|
||||
setShowResultToast: (show) => set({ showResultToast: show }),
|
||||
|
||||
reset: () => set({
|
||||
isBatchDecrypting: false,
|
||||
progress: { current: 0, total: 0 },
|
||||
showToast: false,
|
||||
showResultToast: false,
|
||||
result: { success: 0, fail: 0 },
|
||||
startTime: 0,
|
||||
sessionName: ''
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -2,7 +2,7 @@ import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
export type ThemeId = 'cloud-dancer' | 'corundum-blue' | 'kiwi-green' | 'spicy-red' | 'teal-water'
|
||||
export type ThemeMode = 'light' | 'dark'
|
||||
export type ThemeMode = 'light' | 'dark' | 'system'
|
||||
|
||||
export interface ThemeInfo {
|
||||
id: ThemeId
|
||||
|
||||
@@ -167,6 +167,50 @@
|
||||
}
|
||||
}
|
||||
|
||||
.batch-inline-result-toast {
|
||||
.batch-progress-toast-title {
|
||||
svg {
|
||||
color: #22c55e;
|
||||
}
|
||||
}
|
||||
|
||||
.batch-inline-result-summary {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.batch-inline-result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&.success {
|
||||
color: #16a34a;
|
||||
svg { color: #16a34a; }
|
||||
}
|
||||
|
||||
&.fail {
|
||||
color: #dc2626;
|
||||
svg { color: #dc2626; }
|
||||
}
|
||||
|
||||
&.muted {
|
||||
color: var(--text-tertiary, #999);
|
||||
svg { color: var(--text-tertiary, #999); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 批量转写结果对话框
|
||||
.batch-result-modal {
|
||||
width: 420px;
|
||||
@@ -293,4 +337,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
|
||||
// 卡片背景
|
||||
--card-bg: rgba(255, 255, 255, 0.7);
|
||||
--card-inner-bg: #FAFAF7;
|
||||
--sent-card-bg: var(--primary);
|
||||
}
|
||||
|
||||
// ==================== 浅色主题 ====================
|
||||
@@ -59,6 +61,8 @@
|
||||
--bg-gradient: linear-gradient(135deg, #F0EEE9 0%, #E8E6E1 100%);
|
||||
--primary-gradient: linear-gradient(135deg, #8B7355 0%, #A68B5B 100%);
|
||||
--card-bg: rgba(255, 255, 255, 0.7);
|
||||
--card-inner-bg: #FAFAF7;
|
||||
--sent-card-bg: var(--primary);
|
||||
}
|
||||
|
||||
// 刚玉蓝主题
|
||||
@@ -79,6 +83,8 @@
|
||||
--bg-gradient: linear-gradient(135deg, #E8EEF0 0%, #D8E4E8 100%);
|
||||
--primary-gradient: linear-gradient(135deg, #4A6670 0%, #5A7A86 100%);
|
||||
--card-bg: rgba(255, 255, 255, 0.7);
|
||||
--card-inner-bg: #F8FAFB;
|
||||
--sent-card-bg: var(--primary);
|
||||
}
|
||||
|
||||
// 冰猕猴桃汁绿主题
|
||||
@@ -99,6 +105,8 @@
|
||||
--bg-gradient: linear-gradient(135deg, #E8F0E4 0%, #D8E8D0 100%);
|
||||
--primary-gradient: linear-gradient(135deg, #7A9A5C 0%, #8AAA6C 100%);
|
||||
--card-bg: rgba(255, 255, 255, 0.7);
|
||||
--card-inner-bg: #F8FBF6;
|
||||
--sent-card-bg: var(--primary);
|
||||
}
|
||||
|
||||
// 辛辣红主题
|
||||
@@ -119,6 +127,8 @@
|
||||
--bg-gradient: linear-gradient(135deg, #F0E8E8 0%, #E8D8D8 100%);
|
||||
--primary-gradient: linear-gradient(135deg, #8B4049 0%, #A05058 100%);
|
||||
--card-bg: rgba(255, 255, 255, 0.7);
|
||||
--card-inner-bg: #FAF8F8;
|
||||
--sent-card-bg: var(--primary);
|
||||
}
|
||||
|
||||
// 明水鸭色主题
|
||||
@@ -139,6 +149,8 @@
|
||||
--bg-gradient: linear-gradient(135deg, #E4F0F0 0%, #D4E8E8 100%);
|
||||
--primary-gradient: linear-gradient(135deg, #5A8A8A 0%, #6A9A9A 100%);
|
||||
--card-bg: rgba(255, 255, 255, 0.7);
|
||||
--card-inner-bg: #F6FBFB;
|
||||
--sent-card-bg: var(--primary);
|
||||
}
|
||||
|
||||
// ==================== 深色主题 ====================
|
||||
@@ -160,6 +172,8 @@
|
||||
--bg-gradient: linear-gradient(135deg, #1a1816 0%, #252220 100%);
|
||||
--primary-gradient: linear-gradient(135deg, #8B7355 0%, #C9A86C 100%);
|
||||
--card-bg: rgba(40, 36, 32, 0.9);
|
||||
--card-inner-bg: #27231F;
|
||||
--sent-card-bg: var(--primary);
|
||||
}
|
||||
|
||||
// 刚玉蓝 - 深色
|
||||
@@ -179,6 +193,8 @@
|
||||
--bg-gradient: linear-gradient(135deg, #141a1c 0%, #1e282c 100%);
|
||||
--primary-gradient: linear-gradient(135deg, #4A6670 0%, #6A9AAA 100%);
|
||||
--card-bg: rgba(30, 40, 44, 0.9);
|
||||
--card-inner-bg: #1D272A;
|
||||
--sent-card-bg: var(--primary);
|
||||
}
|
||||
|
||||
// 冰猕猴桃汁绿 - 深色
|
||||
@@ -198,6 +214,8 @@
|
||||
--bg-gradient: linear-gradient(135deg, #161a14 0%, #222a1e 100%);
|
||||
--primary-gradient: linear-gradient(135deg, #7A9A5C 0%, #9ABA7C 100%);
|
||||
--card-bg: rgba(34, 42, 30, 0.9);
|
||||
--card-inner-bg: #21281D;
|
||||
--sent-card-bg: var(--primary);
|
||||
}
|
||||
|
||||
// 辛辣红 - 深色
|
||||
@@ -217,6 +235,8 @@
|
||||
--bg-gradient: linear-gradient(135deg, #1a1416 0%, #2a2022 100%);
|
||||
--primary-gradient: linear-gradient(135deg, #8B4049 0%, #C06068 100%);
|
||||
--card-bg: rgba(42, 32, 34, 0.9);
|
||||
--card-inner-bg: #281F21;
|
||||
--sent-card-bg: var(--primary);
|
||||
}
|
||||
|
||||
// 明水鸭色 - 深色
|
||||
@@ -236,6 +256,8 @@
|
||||
--bg-gradient: linear-gradient(135deg, #121a1a 0%, #1c2a2a 100%);
|
||||
--primary-gradient: linear-gradient(135deg, #5A8A8A 0%, #7ABAAA 100%);
|
||||
--card-bg: rgba(28, 42, 42, 0.9);
|
||||
--card-inner-bg: #1B2828;
|
||||
--sent-card-bg: var(--primary);
|
||||
}
|
||||
|
||||
// 重置样式
|
||||
|
||||
32
src/types/electron.d.ts
vendored
32
src/types/electron.d.ts
vendored
@@ -11,7 +11,7 @@ export interface ElectronAPI {
|
||||
setTitleBarOverlay: (options: { symbolColor: string }) => void
|
||||
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise<void>
|
||||
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
|
||||
openImageViewerWindow: (imagePath: string) => Promise<void>
|
||||
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => Promise<void>
|
||||
openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean>
|
||||
}
|
||||
config: {
|
||||
@@ -19,6 +19,17 @@ export interface ElectronAPI {
|
||||
set: (key: string, value: unknown) => Promise<void>
|
||||
clear: () => Promise<boolean>
|
||||
}
|
||||
auth: {
|
||||
hello: (message?: string) => Promise<{ success: boolean; error?: string }>
|
||||
verifyEnabled: () => Promise<boolean>
|
||||
unlock: (password: string) => Promise<{ success: boolean; error?: string }>
|
||||
enableLock: (password: string) => Promise<{ success: boolean; error?: string }>
|
||||
disableLock: (password: string) => Promise<{ success: boolean; error?: string }>
|
||||
changePassword: (oldPassword: string, newPassword: string) => Promise<{ success: boolean; error?: string }>
|
||||
setHelloSecret: (password: string) => Promise<{ success: boolean }>
|
||||
clearHelloSecret: () => Promise<{ success: boolean }>
|
||||
isLockMode: () => Promise<boolean>
|
||||
}
|
||||
dialog: {
|
||||
openFile: (options?: Electron.OpenDialogOptions) => Promise<Electron.OpenDialogReturnValue>
|
||||
openDirectory: (options?: Electron.OpenDialogOptions) => Promise<Electron.OpenDialogReturnValue>
|
||||
@@ -115,6 +126,11 @@ export interface ElectronAPI {
|
||||
getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||
getAllVoiceMessages: (sessionId: string) => Promise<{ success: boolean; messages?: Message[]; error?: string }>
|
||||
getAllImageMessages: (sessionId: string) => Promise<{
|
||||
success: boolean
|
||||
images?: { imageMd5?: string; imageDatName?: string; createTime?: number }[]
|
||||
error?: string
|
||||
}>
|
||||
resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }>
|
||||
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }>
|
||||
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
|
||||
@@ -125,7 +141,7 @@ export interface ElectronAPI {
|
||||
|
||||
image: {
|
||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; error?: string }>
|
||||
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }>
|
||||
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }>
|
||||
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => Promise<boolean>
|
||||
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
|
||||
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void
|
||||
@@ -503,17 +519,7 @@ export interface ElectronAPI {
|
||||
}) => Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string }>
|
||||
onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void
|
||||
selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }>
|
||||
}
|
||||
llama: {
|
||||
loadModel: (modelPath: string) => Promise<boolean>
|
||||
createSession: (systemPrompt?: string) => Promise<boolean>
|
||||
chat: (message: string) => Promise<{ success: boolean; response?: any; error?: string }>
|
||||
downloadModel: (url: string, savePath: string) => Promise<void>
|
||||
getModelsPath: () => Promise<string>
|
||||
checkFileExists: (filePath: string) => Promise<boolean>
|
||||
getModelStatus: (modelPath: string) => Promise<{ exists: boolean; path?: string; size?: number; error?: string }>
|
||||
onToken: (callback: (token: string) => void) => () => void
|
||||
onDownloadProgress: (callback: (payload: { downloaded: number; total: number; speed: number }) => void) => () => void
|
||||
getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }>
|
||||
}
|
||||
http: {
|
||||
start: (port?: number) => Promise<{ success: boolean; port?: number; error?: string }>
|
||||
|
||||
@@ -32,7 +32,7 @@ export interface ContactInfo {
|
||||
remark?: string
|
||||
nickname?: string
|
||||
avatarUrl?: string
|
||||
type: 'friend' | 'group' | 'official' | 'other'
|
||||
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||
}
|
||||
|
||||
// 消息
|
||||
@@ -64,12 +64,39 @@ export interface Message {
|
||||
fileSize?: number // 文件大小
|
||||
fileExt?: string // 文件扩展名
|
||||
xmlType?: string // XML 中的 type 字段
|
||||
appMsgKind?: string // 归一化 appmsg 类型
|
||||
appMsgDesc?: string
|
||||
appMsgAppName?: string
|
||||
appMsgSourceName?: string
|
||||
appMsgSourceUsername?: string
|
||||
appMsgThumbUrl?: string
|
||||
appMsgMusicUrl?: string
|
||||
appMsgDataUrl?: string
|
||||
appMsgLocationLabel?: string
|
||||
finderNickname?: string
|
||||
finderUsername?: string
|
||||
finderCoverUrl?: string // 视频号封面图
|
||||
finderAvatar?: string // 视频号作者头像
|
||||
finderDuration?: number // 视频号时长(秒)
|
||||
// 位置消息
|
||||
locationLat?: number // 纬度
|
||||
locationLng?: number // 经度
|
||||
locationPoiname?: string // 地点名称
|
||||
locationLabel?: string // 详细地址
|
||||
// 音乐消息
|
||||
musicAlbumUrl?: string // 专辑封面
|
||||
musicUrl?: string // 播放链接
|
||||
// 礼物消息
|
||||
giftImageUrl?: string // 礼物商品图
|
||||
giftWish?: string // 祝福语
|
||||
giftPrice?: string // 价格(分)
|
||||
// 转账消息
|
||||
transferPayerUsername?: string // 转账付款方 wxid
|
||||
transferReceiverUsername?: string // 转账收款方 wxid
|
||||
// 名片消息
|
||||
cardUsername?: string // 名片的微信ID
|
||||
cardNickname?: string // 名片的昵称
|
||||
cardAvatarUrl?: string // 名片头像 URL
|
||||
// 聊天记录
|
||||
chatRecordTitle?: string // 聊天记录标题
|
||||
chatRecordList?: ChatRecordItem[] // 聊天记录列表
|
||||
|
||||
8
src/vite-env.d.ts
vendored
8
src/vite-env.d.ts
vendored
@@ -5,6 +5,14 @@ interface Window {
|
||||
// ... other methods ...
|
||||
auth: {
|
||||
hello: (message?: string) => Promise<{ success: boolean; error?: string }>
|
||||
verifyEnabled: () => Promise<boolean>
|
||||
unlock: (password: string) => Promise<{ success: boolean; error?: string }>
|
||||
enableLock: (password: string) => Promise<{ success: boolean; error?: string }>
|
||||
disableLock: (password: string) => Promise<{ success: boolean; error?: string }>
|
||||
changePassword: (oldPassword: string, newPassword: string) => Promise<{ success: boolean; error?: string }>
|
||||
setHelloSecret: (password: string) => Promise<{ success: boolean }>
|
||||
clearHelloSecret: () => Promise<{ success: boolean }>
|
||||
isLockMode: () => Promise<boolean>
|
||||
}
|
||||
// For brevity, using 'any' for other parts or properly importing types if available.
|
||||
// In a real scenario, you'd likely want to keep the full interface definition consistent with preload.ts
|
||||
|
||||
Reference in New Issue
Block a user