mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
1
.gitignore
vendored
1
.gitignore
vendored
@@ -56,6 +56,7 @@ Thumbs.db
|
|||||||
*.aps
|
*.aps
|
||||||
|
|
||||||
wcdb/
|
wcdb/
|
||||||
|
xkey/
|
||||||
*info
|
*info
|
||||||
概述.md
|
概述.md
|
||||||
chatlab-format.md
|
chatlab-format.md
|
||||||
|
|||||||
@@ -105,7 +105,8 @@ GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&l
|
|||||||
"senderUsername": "wxid_sender",
|
"senderUsername": "wxid_sender",
|
||||||
"mediaType": "image",
|
"mediaType": "image",
|
||||||
"mediaFileName": "image_123.jpg",
|
"mediaFileName": "image_123.jpg",
|
||||||
"mediaPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
|
"mediaUrl": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg",
|
||||||
|
"mediaLocalPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -140,7 +141,7 @@ GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&l
|
|||||||
"timestamp": 1738713600000,
|
"timestamp": 1738713600000,
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"content": "消息内容",
|
"content": "消息内容",
|
||||||
"mediaPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
|
"mediaPath": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"media": {
|
"media": {
|
||||||
@@ -153,7 +154,59 @@ GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&l
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3. 获取会话列表
|
### 3. 访问导出媒体文件
|
||||||
|
|
||||||
|
通过 HTTP 直接访问已导出的媒体文件(图片、语音、视频、表情)。
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
```
|
||||||
|
GET /api/v1/media/{relativePath}
|
||||||
|
```
|
||||||
|
|
||||||
|
**路径参数**
|
||||||
|
|
||||||
|
| 参数名 | 类型 | 必填 | 说明 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| `relativePath` | string | ✅ | 媒体文件的相对路径,如 `wxid_xxx/images/image_123.jpg` |
|
||||||
|
|
||||||
|
**支持的媒体类型**
|
||||||
|
|
||||||
|
| 扩展名 | Content-Type |
|
||||||
|
|--------|-------------|
|
||||||
|
| `.png` | image/png |
|
||||||
|
| `.jpg` / `.jpeg` | image/jpeg |
|
||||||
|
| `.gif` | image/gif |
|
||||||
|
| `.webp` | image/webp |
|
||||||
|
| `.wav` | audio/wav |
|
||||||
|
| `.mp3` | audio/mpeg |
|
||||||
|
| `.mp4` | video/mp4 |
|
||||||
|
|
||||||
|
**示例请求**
|
||||||
|
```bash
|
||||||
|
# 访问导出的图片
|
||||||
|
GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg
|
||||||
|
|
||||||
|
# 访问导出的语音
|
||||||
|
GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/voices/voice_456.wav
|
||||||
|
|
||||||
|
# 访问导出的视频
|
||||||
|
GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/videos/video_789.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
|
||||||
|
成功时直接返回文件内容,`Content-Type` 根据文件扩展名自动设置。
|
||||||
|
|
||||||
|
失败时返回:
|
||||||
|
```json
|
||||||
|
{ "error": "Media not found" }
|
||||||
|
```
|
||||||
|
|
||||||
|
> 注意:媒体文件需要先通过消息接口的 `media=1` 参数导出后才能访问。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 获取会话列表
|
||||||
|
|
||||||
获取所有会话列表。
|
获取所有会话列表。
|
||||||
|
|
||||||
|
|||||||
@@ -1082,6 +1082,26 @@ function registerIpcHandlers() {
|
|||||||
return { canceled: false, filePath: result.filePaths[0] }
|
return { canceled: false, filePath: result.filePaths[0] }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('sns:installBlockDeleteTrigger', async () => {
|
||||||
|
return snsService.installSnsBlockDeleteTrigger()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('sns:uninstallBlockDeleteTrigger', async () => {
|
||||||
|
return snsService.uninstallSnsBlockDeleteTrigger()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('sns:checkBlockDeleteTrigger', async () => {
|
||||||
|
return snsService.checkSnsBlockDeleteTrigger()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('sns:deleteSnsPost', async (_, postId: string) => {
|
||||||
|
return snsService.deleteSnsPost(postId)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('sns:downloadEmoji', async (_, params: { url: string; encryptUrl?: string; aesKey?: string }) => {
|
||||||
|
return snsService.downloadSnsEmoji(params.url, params.encryptUrl, params.aesKey)
|
||||||
|
})
|
||||||
|
|
||||||
// 私聊克隆
|
// 私聊克隆
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -194,11 +194,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 视频
|
// 视频
|
||||||
video: {
|
video: {
|
||||||
getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5),
|
getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5),
|
||||||
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
|
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 数据分析
|
// 数据分析
|
||||||
analytics: {
|
analytics: {
|
||||||
getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force),
|
getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force),
|
||||||
@@ -293,7 +294,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.on('sns:exportProgress', (_, payload) => callback(payload))
|
ipcRenderer.on('sns:exportProgress', (_, payload) => callback(payload))
|
||||||
return () => ipcRenderer.removeAllListeners('sns:exportProgress')
|
return () => ipcRenderer.removeAllListeners('sns:exportProgress')
|
||||||
},
|
},
|
||||||
selectExportDir: () => ipcRenderer.invoke('sns:selectExportDir')
|
selectExportDir: () => ipcRenderer.invoke('sns:selectExportDir'),
|
||||||
|
installBlockDeleteTrigger: () => ipcRenderer.invoke('sns:installBlockDeleteTrigger'),
|
||||||
|
uninstallBlockDeleteTrigger: () => ipcRenderer.invoke('sns:uninstallBlockDeleteTrigger'),
|
||||||
|
checkBlockDeleteTrigger: () => ipcRenderer.invoke('sns:checkBlockDeleteTrigger'),
|
||||||
|
deleteSnsPost: (postId: string) => ipcRenderer.invoke('sns:deleteSnsPost', postId),
|
||||||
|
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params)
|
||||||
},
|
},
|
||||||
|
|
||||||
// HTTP API 服务
|
// HTTP API 服务
|
||||||
|
|||||||
@@ -76,17 +76,13 @@ class AnalyticsService {
|
|||||||
const map: Record<string, string> = {}
|
const map: Record<string, string> = {}
|
||||||
if (usernames.length === 0) return map
|
if (usernames.length === 0) return map
|
||||||
|
|
||||||
|
// C++ 层不支持参数绑定,直接内联转义后的字符串值
|
||||||
const chunkSize = 200
|
const chunkSize = 200
|
||||||
for (let i = 0; i < usernames.length; i += chunkSize) {
|
for (let i = 0; i < usernames.length; i += chunkSize) {
|
||||||
const chunk = usernames.slice(i, i + chunkSize)
|
const chunk = usernames.slice(i, i + chunkSize)
|
||||||
// 使用参数化查询防止SQL注入
|
const inList = chunk.map((u) => `'${this.escapeSqlValue(u)}'`).join(',')
|
||||||
const placeholders = chunk.map(() => '?').join(',')
|
const sql = `SELECT username, alias FROM contact WHERE username IN (${inList})`
|
||||||
const sql = `
|
const result = await wcdbService.execQuery('contact', null, sql)
|
||||||
SELECT username, alias
|
|
||||||
FROM contact
|
|
||||||
WHERE username IN (${placeholders})
|
|
||||||
`
|
|
||||||
const result = await wcdbService.execQuery('contact', null, sql, chunk)
|
|
||||||
if (!result.success || !result.rows) continue
|
if (!result.success || !result.rows) continue
|
||||||
for (const row of result.rows as Record<string, any>[]) {
|
for (const row of result.rows as Record<string, any>[]) {
|
||||||
const username = row.username || ''
|
const username = row.username || ''
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export interface ChatSession {
|
|||||||
lastMsgSender?: string
|
lastMsgSender?: string
|
||||||
lastSenderDisplayName?: string
|
lastSenderDisplayName?: string
|
||||||
selfWxid?: string
|
selfWxid?: string
|
||||||
|
isFolded?: boolean // 是否已折叠进"折叠的群聊"
|
||||||
|
isMuted?: boolean // 是否开启免打扰
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
@@ -413,12 +415,29 @@ class ChatService {
|
|||||||
lastMsgType,
|
lastMsgType,
|
||||||
displayName,
|
displayName,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
lastMsgSender: row.last_msg_sender, // 数据库返回字段
|
lastMsgSender: row.last_msg_sender,
|
||||||
lastSenderDisplayName: row.last_sender_display_name, // 数据库返回字段
|
lastSenderDisplayName: row.last_sender_display_name,
|
||||||
selfWxid: myWxid
|
selfWxid: myWxid
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 批量拉取 extra_buffer 状态(isFolded/isMuted),不阻塞主流程
|
||||||
|
const allUsernames = sessions.map(s => s.username)
|
||||||
|
try {
|
||||||
|
const statusResult = await wcdbService.getContactStatus(allUsernames)
|
||||||
|
if (statusResult.success && statusResult.map) {
|
||||||
|
for (const s of sessions) {
|
||||||
|
const st = statusResult.map[s.username]
|
||||||
|
if (st) {
|
||||||
|
s.isFolded = st.isFolded
|
||||||
|
s.isMuted = st.isMuted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 状态获取失败不影响会话列表返回
|
||||||
|
}
|
||||||
|
|
||||||
// 不等待联系人信息加载,直接返回基础会话列表
|
// 不等待联系人信息加载,直接返回基础会话列表
|
||||||
// 前端可以异步调用 enrichSessionsWithContacts 来补充信息
|
// 前端可以异步调用 enrichSessionsWithContacts 来补充信息
|
||||||
return { success: true, sessions }
|
return { success: true, sessions }
|
||||||
@@ -2846,15 +2865,16 @@ class ChatService {
|
|||||||
private shouldKeepSession(username: string): boolean {
|
private shouldKeepSession(username: string): boolean {
|
||||||
if (!username) return false
|
if (!username) return false
|
||||||
const lowered = username.toLowerCase()
|
const lowered = username.toLowerCase()
|
||||||
if (lowered.includes('@placeholder') || lowered.includes('foldgroup')) return false
|
// placeholder_foldgroup 是折叠群入口,需要保留
|
||||||
|
if (lowered.includes('@placeholder') && !lowered.includes('foldgroup')) return false
|
||||||
if (username.startsWith('gh_')) return false
|
if (username.startsWith('gh_')) return false
|
||||||
|
|
||||||
const excludeList = [
|
const excludeList = [
|
||||||
'weixin', 'qqmail', 'fmessage', 'medianote', 'floatbottle',
|
'weixin', 'qqmail', 'fmessage', 'medianote', 'floatbottle',
|
||||||
'newsapp', 'brandsessionholder', 'brandservicesessionholder',
|
'newsapp', 'brandsessionholder', 'brandservicesessionholder',
|
||||||
'notifymessage', 'opencustomerservicemsg', 'notification_messages',
|
'notifymessage', 'opencustomerservicemsg', 'notification_messages',
|
||||||
'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup',
|
'userexperience_alarm', 'helper_folders',
|
||||||
'@helper_folders', '@placeholder_foldgroup'
|
'@helper_folders'
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const prefix of excludeList) {
|
for (const prefix of excludeList) {
|
||||||
@@ -4478,77 +4498,27 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private resolveAccountDir(dbPath: string, wxid: string): string | null {
|
private resolveAccountDir(dbPath: string, wxid: string): string | null {
|
||||||
const cleanedWxid = this.cleanAccountDirName(wxid).toLowerCase()
|
const normalized = dbPath.replace(/[\\\\/]+$/, '')
|
||||||
const normalized = dbPath.replace(/[\\/]+$/, '')
|
|
||||||
|
|
||||||
const candidates: { path: string; mtime: number }[] = []
|
// 如果 dbPath 本身指向 db_storage 目录下的文件(如某个 .db 文件)
|
||||||
|
// 则向上回溯到账号目录
|
||||||
// 检查直接路径
|
if (basename(normalized).toLowerCase() === 'db_storage') {
|
||||||
const direct = join(normalized, cleanedWxid)
|
return dirname(normalized)
|
||||||
if (existsSync(direct) && this.isAccountDir(direct)) {
|
}
|
||||||
candidates.push({ path: direct, mtime: this.getDirMtime(direct) })
|
const dir = dirname(normalized)
|
||||||
|
if (basename(dir).toLowerCase() === 'db_storage') {
|
||||||
|
return dirname(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查 dbPath 本身是否就是账号目录
|
// 否则,dbPath 应该是数据库根目录(如 xwechat_files)
|
||||||
if (this.isAccountDir(normalized)) {
|
// 账号目录应该是 {dbPath}/{wxid}
|
||||||
candidates.push({ path: normalized, mtime: this.getDirMtime(normalized) })
|
const accountDirWithWxid = join(normalized, wxid)
|
||||||
|
if (existsSync(accountDirWithWxid)) {
|
||||||
|
return accountDirWithWxid
|
||||||
}
|
}
|
||||||
|
|
||||||
// 扫描 dbPath 下的所有子目录寻找匹配的 wxid
|
// 兜底:返回 dbPath 本身(可能 dbPath 已经是账号目录)
|
||||||
try {
|
return normalized
|
||||||
if (existsSync(normalized) && statSync(normalized).isDirectory()) {
|
|
||||||
const entries = readdirSync(normalized)
|
|
||||||
for (const entry of entries) {
|
|
||||||
const entryPath = join(normalized, entry)
|
|
||||||
try {
|
|
||||||
if (!statSync(entryPath).isDirectory()) continue
|
|
||||||
} catch { continue }
|
|
||||||
|
|
||||||
const lowerEntry = entry.toLowerCase()
|
|
||||||
if (lowerEntry === cleanedWxid || lowerEntry.startsWith(`${cleanedWxid}_`)) {
|
|
||||||
if (this.isAccountDir(entryPath)) {
|
|
||||||
if (!candidates.some(c => c.path === entryPath)) {
|
|
||||||
candidates.push({ path: entryPath, mtime: this.getDirMtime(entryPath) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch { }
|
|
||||||
|
|
||||||
if (candidates.length === 0) return null
|
|
||||||
|
|
||||||
// 按修改时间降序排序,取最新的
|
|
||||||
candidates.sort((a, b) => b.mtime - a.mtime)
|
|
||||||
return candidates[0].path
|
|
||||||
}
|
|
||||||
|
|
||||||
private isAccountDir(dirPath: string): boolean {
|
|
||||||
return (
|
|
||||||
existsSync(join(dirPath, 'db_storage')) ||
|
|
||||||
existsSync(join(dirPath, 'FileStorage', 'Image')) ||
|
|
||||||
existsSync(join(dirPath, 'FileStorage', 'Image2')) ||
|
|
||||||
existsSync(join(dirPath, 'msg', 'attach'))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private getDirMtime(dirPath: string): number {
|
|
||||||
try {
|
|
||||||
const stat = statSync(dirPath)
|
|
||||||
let mtime = stat.mtimeMs
|
|
||||||
const subDirs = ['db_storage', 'msg/attach', 'FileStorage/Image']
|
|
||||||
for (const sub of subDirs) {
|
|
||||||
const fullPath = join(dirPath, sub)
|
|
||||||
if (existsSync(fullPath)) {
|
|
||||||
try {
|
|
||||||
mtime = Math.max(mtime, statSync(fullPath).mtimeMs)
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return mtime
|
|
||||||
} catch {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findDatFile(accountDir: string, baseName: string, sessionId?: string): Promise<string | null> {
|
private async findDatFile(accountDir: string, baseName: string, sessionId?: string): Promise<string | null> {
|
||||||
|
|||||||
@@ -77,8 +77,7 @@ export class DbPathService {
|
|||||||
return (
|
return (
|
||||||
existsSync(join(entryPath, 'db_storage')) ||
|
existsSync(join(entryPath, 'db_storage')) ||
|
||||||
existsSync(join(entryPath, 'FileStorage', 'Image')) ||
|
existsSync(join(entryPath, 'FileStorage', 'Image')) ||
|
||||||
existsSync(join(entryPath, 'FileStorage', 'Image2')) ||
|
existsSync(join(entryPath, 'FileStorage', 'Image2'))
|
||||||
existsSync(join(entryPath, 'msg', 'attach'))
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,21 +94,22 @@ export class DbPathService {
|
|||||||
const accountStat = statSync(entryPath)
|
const accountStat = statSync(entryPath)
|
||||||
let latest = accountStat.mtimeMs
|
let latest = accountStat.mtimeMs
|
||||||
|
|
||||||
const checkSubDirs = [
|
const dbPath = join(entryPath, 'db_storage')
|
||||||
'db_storage',
|
if (existsSync(dbPath)) {
|
||||||
join('FileStorage', 'Image'),
|
const dbStat = statSync(dbPath)
|
||||||
join('FileStorage', 'Image2'),
|
latest = Math.max(latest, dbStat.mtimeMs)
|
||||||
join('msg', 'attach')
|
}
|
||||||
]
|
|
||||||
|
|
||||||
for (const sub of checkSubDirs) {
|
const imagePath = join(entryPath, 'FileStorage', 'Image')
|
||||||
const fullPath = join(entryPath, sub)
|
if (existsSync(imagePath)) {
|
||||||
if (existsSync(fullPath)) {
|
const imageStat = statSync(imagePath)
|
||||||
try {
|
latest = Math.max(latest, imageStat.mtimeMs)
|
||||||
const s = statSync(fullPath)
|
}
|
||||||
latest = Math.max(latest, s.mtimeMs)
|
|
||||||
} catch { }
|
const image2Path = join(entryPath, 'FileStorage', 'Image2')
|
||||||
}
|
if (existsSync(image2Path)) {
|
||||||
|
const image2Stat = statSync(image2Path)
|
||||||
|
latest = Math.max(latest, image2Stat.mtimeMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
return latest
|
return latest
|
||||||
|
|||||||
@@ -665,7 +665,18 @@ class ExportService {
|
|||||||
case 42: return '[名片]'
|
case 42: return '[名片]'
|
||||||
case 43: return '[视频]'
|
case 43: return '[视频]'
|
||||||
case 47: return '[动画表情]'
|
case 47: return '[动画表情]'
|
||||||
case 48: return '[位置]'
|
case 48: {
|
||||||
|
const normalized48 = this.normalizeAppMessageContent(content)
|
||||||
|
const locPoiname = this.extractXmlAttribute(normalized48, 'location', 'poiname') || this.extractXmlValue(normalized48, 'poiname') || this.extractXmlValue(normalized48, 'poiName')
|
||||||
|
const locLabel = this.extractXmlAttribute(normalized48, 'location', 'label') || this.extractXmlValue(normalized48, 'label')
|
||||||
|
const locLat = this.extractXmlAttribute(normalized48, 'location', 'x') || this.extractXmlAttribute(normalized48, 'location', 'latitude')
|
||||||
|
const locLng = this.extractXmlAttribute(normalized48, 'location', 'y') || this.extractXmlAttribute(normalized48, 'location', 'longitude')
|
||||||
|
const locParts: string[] = []
|
||||||
|
if (locPoiname) locParts.push(locPoiname)
|
||||||
|
if (locLabel && locLabel !== locPoiname) locParts.push(locLabel)
|
||||||
|
if (locLat && locLng) locParts.push(`(${locLat},${locLng})`)
|
||||||
|
return locParts.length > 0 ? `[位置] ${locParts.join(' ')}` : '[位置]'
|
||||||
|
}
|
||||||
case 49: {
|
case 49: {
|
||||||
const title = this.extractXmlValue(content, 'title')
|
const title = this.extractXmlValue(content, 'title')
|
||||||
const type = this.extractXmlValue(content, 'type')
|
const type = this.extractXmlValue(content, 'type')
|
||||||
@@ -776,12 +787,15 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
if (localType === 48) {
|
if (localType === 48) {
|
||||||
const normalized = this.normalizeAppMessageContent(safeContent)
|
const normalized = this.normalizeAppMessageContent(safeContent)
|
||||||
const location =
|
const locPoiname = this.extractXmlAttribute(normalized, 'location', 'poiname') || this.extractXmlValue(normalized, 'poiname') || this.extractXmlValue(normalized, 'poiName')
|
||||||
this.extractXmlValue(normalized, 'label') ||
|
const locLabel = this.extractXmlAttribute(normalized, 'location', 'label') || this.extractXmlValue(normalized, 'label')
|
||||||
this.extractXmlValue(normalized, 'poiname') ||
|
const locLat = this.extractXmlAttribute(normalized, 'location', 'x') || this.extractXmlAttribute(normalized, 'location', 'latitude')
|
||||||
this.extractXmlValue(normalized, 'poiName') ||
|
const locLng = this.extractXmlAttribute(normalized, 'location', 'y') || this.extractXmlAttribute(normalized, 'location', 'longitude')
|
||||||
this.extractXmlValue(normalized, 'name')
|
const locParts: string[] = []
|
||||||
return location ? `[定位]${location}` : '[定位]'
|
if (locPoiname) locParts.push(locPoiname)
|
||||||
|
if (locLabel && locLabel !== locPoiname) locParts.push(locLabel)
|
||||||
|
if (locLat && locLng) locParts.push(`(${locLat},${locLng})`)
|
||||||
|
return locParts.length > 0 ? `[位置] ${locParts.join(' ')}` : '[位置]'
|
||||||
}
|
}
|
||||||
if (localType === 50) {
|
if (localType === 50) {
|
||||||
return this.parseVoipMessage(safeContent)
|
return this.parseVoipMessage(safeContent)
|
||||||
@@ -979,6 +993,12 @@ class ExportService {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extractXmlAttribute(xml: string, tagName: string, attrName: string): string {
|
||||||
|
const tagRegex = new RegExp(`<${tagName}\\s+[^>]*${attrName}\\s*=\\s*"([^"]*)"`, 'i')
|
||||||
|
const match = tagRegex.exec(xml)
|
||||||
|
return match ? match[1] : ''
|
||||||
|
}
|
||||||
|
|
||||||
private cleanSystemMessage(content: string): string {
|
private cleanSystemMessage(content: string): string {
|
||||||
if (!content) return '[系统消息]'
|
if (!content) return '[系统消息]'
|
||||||
|
|
||||||
@@ -2932,7 +2952,7 @@ class ExportService {
|
|||||||
options.displayNamePreference || 'remark'
|
options.displayNamePreference || 'remark'
|
||||||
)
|
)
|
||||||
|
|
||||||
allMessages.push({
|
const msgObj: any = {
|
||||||
localId: allMessages.length + 1,
|
localId: allMessages.length + 1,
|
||||||
createTime: msg.createTime,
|
createTime: msg.createTime,
|
||||||
formattedTime: this.formatTimestamp(msg.createTime),
|
formattedTime: this.formatTimestamp(msg.createTime),
|
||||||
@@ -2944,7 +2964,17 @@ class ExportService {
|
|||||||
senderDisplayName,
|
senderDisplayName,
|
||||||
source,
|
source,
|
||||||
senderAvatarKey: msg.senderUsername
|
senderAvatarKey: msg.senderUsername
|
||||||
})
|
}
|
||||||
|
|
||||||
|
// 位置消息:附加结构化位置字段
|
||||||
|
if (msg.localType === 48) {
|
||||||
|
if (msg.locationLat != null) msgObj.locationLat = msg.locationLat
|
||||||
|
if (msg.locationLng != null) msgObj.locationLng = msg.locationLng
|
||||||
|
if (msg.locationPoiname) msgObj.locationPoiname = msg.locationPoiname
|
||||||
|
if (msg.locationLabel) msgObj.locationLabel = msg.locationLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
allMessages.push(msgObj)
|
||||||
}
|
}
|
||||||
|
|
||||||
allMessages.sort((a, b) => a.createTime - b.createTime)
|
allMessages.sort((a, b) => a.createTime - b.createTime)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { chatService, Message } from './chatService'
|
|||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { videoService } from './videoService'
|
import { videoService } from './videoService'
|
||||||
|
import { imageDecryptService } from './imageDecryptService'
|
||||||
|
|
||||||
// ChatLab 格式定义
|
// ChatLab 格式定义
|
||||||
interface ChatLabHeader {
|
interface ChatLabHeader {
|
||||||
@@ -69,6 +70,7 @@ interface ApiExportedMedia {
|
|||||||
kind: MediaKind
|
kind: MediaKind
|
||||||
fileName: string
|
fileName: string
|
||||||
fullPath: string
|
fullPath: string
|
||||||
|
relativePath: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChatLab 消息类型映射
|
// ChatLab 消息类型映射
|
||||||
@@ -236,6 +238,8 @@ class HttpService {
|
|||||||
await this.handleSessions(url, res)
|
await this.handleSessions(url, res)
|
||||||
} else if (pathname === '/api/v1/contacts') {
|
} else if (pathname === '/api/v1/contacts') {
|
||||||
await this.handleContacts(url, res)
|
await this.handleContacts(url, res)
|
||||||
|
} else if (pathname.startsWith('/api/v1/media/')) {
|
||||||
|
this.handleMediaRequest(pathname, res)
|
||||||
} else {
|
} else {
|
||||||
this.sendError(res, 404, 'Not Found')
|
this.sendError(res, 404, 'Not Found')
|
||||||
}
|
}
|
||||||
@@ -245,6 +249,40 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleMediaRequest(pathname: string, res: http.ServerResponse): void {
|
||||||
|
const mediaBasePath = this.getApiMediaExportPath()
|
||||||
|
const relativePath = pathname.replace('/api/v1/media/', '')
|
||||||
|
const fullPath = path.join(mediaBasePath, relativePath)
|
||||||
|
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
this.sendError(res, 404, 'Media not found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(fullPath).toLowerCase()
|
||||||
|
const mimeTypes: Record<string, string> = {
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.wav': 'audio/wav',
|
||||||
|
'.mp3': 'audio/mpeg',
|
||||||
|
'.mp4': 'video/mp4'
|
||||||
|
}
|
||||||
|
const contentType = mimeTypes[ext] || 'application/octet-stream'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileBuffer = fs.readFileSync(fullPath)
|
||||||
|
res.setHeader('Content-Type', contentType)
|
||||||
|
res.setHeader('Content-Length', fileBuffer.length)
|
||||||
|
res.writeHead(200)
|
||||||
|
res.end(fileBuffer)
|
||||||
|
} catch (e) {
|
||||||
|
this.sendError(res, 500, 'Failed to read media file')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量获取消息(循环游标直到满足 limit)
|
* 批量获取消息(循环游标直到满足 limit)
|
||||||
* 绕过 chatService 的单 batch 限制,直接操作 wcdbService 游标
|
* 绕过 chatService 的单 batch 限制,直接操作 wcdbService 游标
|
||||||
@@ -380,7 +418,7 @@ class HttpService {
|
|||||||
const queryOffset = keyword ? 0 : offset
|
const queryOffset = keyword ? 0 : offset
|
||||||
const queryLimit = keyword ? 10000 : limit
|
const queryLimit = keyword ? 10000 : limit
|
||||||
|
|
||||||
const result = await this.fetchMessagesBatch(talker, queryOffset, queryLimit, startTime, endTime, true)
|
const result = await this.fetchMessagesBatch(talker, queryOffset, queryLimit, startTime, endTime, false)
|
||||||
if (!result.success || !result.messages) {
|
if (!result.success || !result.messages) {
|
||||||
this.sendError(res, 500, result.error || 'Failed to get messages')
|
this.sendError(res, 500, result.error || 'Failed to get messages')
|
||||||
return
|
return
|
||||||
@@ -576,19 +614,44 @@ class HttpService {
|
|||||||
): Promise<ApiExportedMedia | null> {
|
): Promise<ApiExportedMedia | null> {
|
||||||
try {
|
try {
|
||||||
if (msg.localType === 3 && options.exportImages) {
|
if (msg.localType === 3 && options.exportImages) {
|
||||||
const result = await chatService.getImageData(talker, String(msg.localId))
|
const result = await imageDecryptService.decryptImage({
|
||||||
if (result.success && result.data) {
|
sessionId: talker,
|
||||||
const imageBuffer = Buffer.from(result.data, 'base64')
|
imageMd5: msg.imageMd5,
|
||||||
const ext = this.detectImageExt(imageBuffer)
|
imageDatName: msg.imageDatName,
|
||||||
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
force: true
|
||||||
const fileName = `${fileBase}${ext}`
|
})
|
||||||
const targetDir = path.join(sessionDir, 'images')
|
if (result.success && result.localPath) {
|
||||||
const fullPath = path.join(targetDir, fileName)
|
let imagePath = result.localPath
|
||||||
this.ensureDir(targetDir)
|
if (imagePath.startsWith('data:')) {
|
||||||
if (!fs.existsSync(fullPath)) {
|
const base64Match = imagePath.match(/^data:[^;]+;base64,(.+)$/)
|
||||||
fs.writeFileSync(fullPath, imageBuffer)
|
if (base64Match) {
|
||||||
|
const imageBuffer = Buffer.from(base64Match[1], 'base64')
|
||||||
|
const ext = this.detectImageExt(imageBuffer)
|
||||||
|
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
||||||
|
const fileName = `${fileBase}${ext}`
|
||||||
|
const targetDir = path.join(sessionDir, 'images')
|
||||||
|
const fullPath = path.join(targetDir, fileName)
|
||||||
|
this.ensureDir(targetDir)
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
fs.writeFileSync(fullPath, imageBuffer)
|
||||||
|
}
|
||||||
|
const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
|
||||||
|
return { kind: 'image', fileName, fullPath, relativePath }
|
||||||
|
}
|
||||||
|
} else if (fs.existsSync(imagePath)) {
|
||||||
|
const imageBuffer = fs.readFileSync(imagePath)
|
||||||
|
const ext = this.detectImageExt(imageBuffer)
|
||||||
|
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
||||||
|
const fileName = `${fileBase}${ext}`
|
||||||
|
const targetDir = path.join(sessionDir, 'images')
|
||||||
|
const fullPath = path.join(targetDir, fileName)
|
||||||
|
this.ensureDir(targetDir)
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
fs.copyFileSync(imagePath, fullPath)
|
||||||
|
}
|
||||||
|
const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
|
||||||
|
return { kind: 'image', fileName, fullPath, relativePath }
|
||||||
}
|
}
|
||||||
return { kind: 'image', fileName, fullPath }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,7 +670,8 @@ class HttpService {
|
|||||||
if (!fs.existsSync(fullPath)) {
|
if (!fs.existsSync(fullPath)) {
|
||||||
fs.writeFileSync(fullPath, Buffer.from(result.data, 'base64'))
|
fs.writeFileSync(fullPath, Buffer.from(result.data, 'base64'))
|
||||||
}
|
}
|
||||||
return { kind: 'voice', fileName, fullPath }
|
const relativePath = `${this.sanitizeFileName(talker, 'session')}/voices/${fileName}`
|
||||||
|
return { kind: 'voice', fileName, fullPath, relativePath }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,7 +686,8 @@ class HttpService {
|
|||||||
if (!fs.existsSync(fullPath)) {
|
if (!fs.existsSync(fullPath)) {
|
||||||
fs.copyFileSync(info.videoUrl, fullPath)
|
fs.copyFileSync(info.videoUrl, fullPath)
|
||||||
}
|
}
|
||||||
return { kind: 'video', fileName, fullPath }
|
const relativePath = `${this.sanitizeFileName(talker, 'session')}/videos/${fileName}`
|
||||||
|
return { kind: 'video', fileName, fullPath, relativePath }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -637,7 +702,8 @@ class HttpService {
|
|||||||
if (!fs.existsSync(fullPath)) {
|
if (!fs.existsSync(fullPath)) {
|
||||||
fs.copyFileSync(result.localPath, fullPath)
|
fs.copyFileSync(result.localPath, fullPath)
|
||||||
}
|
}
|
||||||
return { kind: 'emoji', fileName, fullPath }
|
const relativePath = `${this.sanitizeFileName(talker, 'session')}/emojis/${fileName}`
|
||||||
|
return { kind: 'emoji', fileName, fullPath, relativePath }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -661,7 +727,8 @@ class HttpService {
|
|||||||
parsedContent: msg.parsedContent,
|
parsedContent: msg.parsedContent,
|
||||||
mediaType: media?.kind,
|
mediaType: media?.kind,
|
||||||
mediaFileName: media?.fileName,
|
mediaFileName: media?.fileName,
|
||||||
mediaPath: media?.fullPath
|
mediaUrl: media ? `http://127.0.0.1:${this.port}/api/v1/media/${media.relativePath}` : undefined,
|
||||||
|
mediaLocalPath: media?.fullPath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -784,7 +851,7 @@ class HttpService {
|
|||||||
type: this.mapMessageType(msg.localType, msg),
|
type: this.mapMessageType(msg.localType, msg),
|
||||||
content: this.getMessageContent(msg),
|
content: this.getMessageContent(msg),
|
||||||
platformMessageId: msg.serverId ? String(msg.serverId) : undefined,
|
platformMessageId: msg.serverId ? String(msg.serverId) : undefined,
|
||||||
mediaPath: mediaMap.get(msg.localId)?.fullPath
|
mediaPath: mediaMap.get(msg.localId) ? `http://127.0.0.1:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ export class ImageDecryptService {
|
|||||||
if (finalExt === '.hevc') {
|
if (finalExt === '.hevc') {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: '此图片为微信新格式 (wxgf),需要安装 ffmpeg 才能显示',
|
error: '此图片为微信新格式(wxgf),ffmpeg 转换失败,请检查日志',
|
||||||
isThumb: this.isThumbnailPath(datPath)
|
isThumb: this.isThumbnailPath(datPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1664,21 +1664,24 @@ export class ImageDecryptService {
|
|||||||
|
|
||||||
// 提取 HEVC NALU 裸流
|
// 提取 HEVC NALU 裸流
|
||||||
const hevcData = this.extractHevcNalu(buffer)
|
const hevcData = this.extractHevcNalu(buffer)
|
||||||
if (!hevcData || hevcData.length < 100) {
|
// 优先用提取的 NALU 裸流,提取失败则跳过 wxgf 头部直接用原始数据
|
||||||
return { data: buffer, isWxgf: true }
|
const feedData = (hevcData && hevcData.length >= 100) ? hevcData : buffer.subarray(4)
|
||||||
}
|
this.logInfo('unwrapWxgf: 准备 ffmpeg 转换', {
|
||||||
|
naluExtracted: !!(hevcData && hevcData.length >= 100),
|
||||||
|
feedSize: feedData.length
|
||||||
|
})
|
||||||
|
|
||||||
// 尝试用 ffmpeg 转换
|
// 尝试用 ffmpeg 转换
|
||||||
try {
|
try {
|
||||||
const jpgData = await this.convertHevcToJpg(hevcData)
|
const jpgData = await this.convertHevcToJpg(feedData)
|
||||||
if (jpgData && jpgData.length > 0) {
|
if (jpgData && jpgData.length > 0) {
|
||||||
return { data: jpgData, isWxgf: false }
|
return { data: jpgData, isWxgf: false }
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
// ffmpeg 转换失败
|
this.logError('unwrapWxgf: ffmpeg 转换失败', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { data: hevcData, isWxgf: true }
|
return { data: feedData, isWxgf: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1745,50 +1748,92 @@ export class ImageDecryptService {
|
|||||||
/**
|
/**
|
||||||
* 使用 ffmpeg 将 HEVC 裸流转换为 JPG
|
* 使用 ffmpeg 将 HEVC 裸流转换为 JPG
|
||||||
*/
|
*/
|
||||||
private convertHevcToJpg(hevcData: Buffer): Promise<Buffer | null> {
|
private async convertHevcToJpg(hevcData: Buffer): Promise<Buffer | null> {
|
||||||
const ffmpeg = this.getFfmpegPath()
|
const ffmpeg = this.getFfmpegPath()
|
||||||
this.logInfo('ffmpeg 转换开始', { ffmpegPath: ffmpeg, hevcSize: hevcData.length })
|
this.logInfo('ffmpeg 转换开始', { ffmpegPath: ffmpeg, hevcSize: hevcData.length })
|
||||||
|
|
||||||
|
const tmpDir = join(app.getPath('temp'), 'weflow_hevc')
|
||||||
|
if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true })
|
||||||
|
const ts = Date.now()
|
||||||
|
const tmpInput = join(tmpDir, `hevc_${ts}.hevc`)
|
||||||
|
const tmpOutput = join(tmpDir, `hevc_${ts}.jpg`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeFile(tmpInput, hevcData)
|
||||||
|
|
||||||
|
// 依次尝试: 1) -f hevc 裸流 2) 不指定格式让 ffmpeg 自动检测
|
||||||
|
const attempts: { label: string; inputArgs: string[] }[] = [
|
||||||
|
{ label: 'hevc raw', inputArgs: ['-f', 'hevc', '-i', tmpInput] },
|
||||||
|
{ label: 'auto detect', inputArgs: ['-i', tmpInput] },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const attempt of attempts) {
|
||||||
|
// 清理上一轮的输出
|
||||||
|
try { if (existsSync(tmpOutput)) require('fs').unlinkSync(tmpOutput) } catch {}
|
||||||
|
|
||||||
|
const result = await this.runFfmpegConvert(ffmpeg, attempt.inputArgs, tmpOutput, attempt.label)
|
||||||
|
if (result) return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
} catch (e) {
|
||||||
|
this.logError('ffmpeg 转换异常', e)
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
try { if (existsSync(tmpInput)) require('fs').unlinkSync(tmpInput) } catch {}
|
||||||
|
try { if (existsSync(tmpOutput)) require('fs').unlinkSync(tmpOutput) } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private runFfmpegConvert(ffmpeg: string, inputArgs: string[], tmpOutput: string, label: string): Promise<Buffer | null> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const { spawn } = require('child_process')
|
const { spawn } = require('child_process')
|
||||||
const chunks: Buffer[] = []
|
|
||||||
const errChunks: Buffer[] = []
|
const errChunks: Buffer[] = []
|
||||||
|
|
||||||
const proc = spawn(ffmpeg, [
|
const args = [
|
||||||
'-hide_banner',
|
'-hide_banner', '-loglevel', 'error',
|
||||||
'-loglevel', 'error',
|
...inputArgs,
|
||||||
'-f', 'hevc',
|
'-vframes', '1', '-q:v', '2', '-f', 'image2', tmpOutput
|
||||||
'-i', 'pipe:0',
|
]
|
||||||
'-vframes', '1',
|
this.logInfo(`ffmpeg 尝试 [${label}]`, { args: args.join(' ') })
|
||||||
'-q:v', '3',
|
|
||||||
'-f', 'mjpeg',
|
const proc = spawn(ffmpeg, args, {
|
||||||
'pipe:1'
|
stdio: ['ignore', 'ignore', 'pipe'],
|
||||||
], {
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
windowsHide: true
|
windowsHide: true
|
||||||
})
|
})
|
||||||
|
|
||||||
proc.stdout.on('data', (chunk: Buffer) => chunks.push(chunk))
|
|
||||||
proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk))
|
proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk))
|
||||||
|
|
||||||
proc.on('close', (code: number) => {
|
const timer = setTimeout(() => {
|
||||||
if (code === 0 && chunks.length > 0) {
|
proc.kill('SIGKILL')
|
||||||
this.logInfo('ffmpeg 转换成功', { outputSize: Buffer.concat(chunks).length })
|
this.logError(`ffmpeg [${label}] 超时(15s)`)
|
||||||
resolve(Buffer.concat(chunks))
|
resolve(null)
|
||||||
} else {
|
}, 15000)
|
||||||
const errMsg = Buffer.concat(errChunks).toString()
|
|
||||||
this.logInfo('ffmpeg 转换失败', { code, error: errMsg })
|
|
||||||
resolve(null)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
proc.on('error', (err: Error) => {
|
proc.on('close', (code: number) => {
|
||||||
this.logInfo('ffmpeg 进程错误', { error: err.message })
|
clearTimeout(timer)
|
||||||
|
if (code === 0 && existsSync(tmpOutput)) {
|
||||||
|
try {
|
||||||
|
const jpgBuf = readFileSync(tmpOutput)
|
||||||
|
if (jpgBuf.length > 0) {
|
||||||
|
this.logInfo(`ffmpeg [${label}] 成功`, { outputSize: jpgBuf.length })
|
||||||
|
resolve(jpgBuf)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logError(`ffmpeg [${label}] 读取输出失败`, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const errMsg = Buffer.concat(errChunks).toString().trim()
|
||||||
|
this.logInfo(`ffmpeg [${label}] 失败`, { code, error: errMsg })
|
||||||
resolve(null)
|
resolve(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
proc.stdin.write(hevcData)
|
proc.on('error', (err: Error) => {
|
||||||
proc.stdin.end()
|
clearTimeout(timer)
|
||||||
|
this.logError(`ffmpeg [${label}] 进程错误`, err)
|
||||||
|
resolve(null)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import { join, dirname, basename } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { existsSync, readdirSync, readFileSync, statSync, copyFileSync, mkdirSync } from 'fs'
|
import { existsSync, copyFileSync, mkdirSync } from 'fs'
|
||||||
import { execFile, spawn } from 'child_process'
|
import { execFile, spawn } from 'child_process'
|
||||||
import { promisify } from 'util'
|
import { promisify } from 'util'
|
||||||
import crypto from 'crypto'
|
|
||||||
import os from 'os'
|
import os from 'os'
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile)
|
const execFileAsync = promisify(execFile)
|
||||||
@@ -20,6 +19,7 @@ export class KeyService {
|
|||||||
private getStatusMessage: any = null
|
private getStatusMessage: any = null
|
||||||
private cleanupHook: any = null
|
private cleanupHook: any = null
|
||||||
private getLastErrorMsg: any = null
|
private getLastErrorMsg: any = null
|
||||||
|
private getImageKeyDll: any = null
|
||||||
|
|
||||||
// Win32 APIs
|
// Win32 APIs
|
||||||
private kernel32: any = null
|
private kernel32: any = null
|
||||||
@@ -29,9 +29,6 @@ export class KeyService {
|
|||||||
// Kernel32
|
// Kernel32
|
||||||
private OpenProcess: any = null
|
private OpenProcess: any = null
|
||||||
private CloseHandle: any = null
|
private CloseHandle: any = null
|
||||||
private VirtualQueryEx: any = null
|
|
||||||
private ReadProcessMemory: any = null
|
|
||||||
private MEMORY_BASIC_INFORMATION: any = null
|
|
||||||
private TerminateProcess: any = null
|
private TerminateProcess: any = null
|
||||||
private QueryFullProcessImageNameW: any = null
|
private QueryFullProcessImageNameW: any = null
|
||||||
|
|
||||||
@@ -62,50 +59,33 @@ export class KeyService {
|
|||||||
|
|
||||||
private getDllPath(): string {
|
private getDllPath(): string {
|
||||||
const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
|
const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
|
||||||
|
|
||||||
// 候选路径列表
|
|
||||||
const candidates: string[] = []
|
const candidates: string[] = []
|
||||||
|
|
||||||
// 1. 显式环境变量 (最高优先级)
|
|
||||||
if (process.env.WX_KEY_DLL_PATH) {
|
if (process.env.WX_KEY_DLL_PATH) {
|
||||||
candidates.push(process.env.WX_KEY_DLL_PATH)
|
candidates.push(process.env.WX_KEY_DLL_PATH)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPackaged) {
|
if (isPackaged) {
|
||||||
// 生产环境: 通常在 resources 目录下,但也可能直接在 resources 根目录
|
|
||||||
candidates.push(join(process.resourcesPath, 'resources', 'wx_key.dll'))
|
candidates.push(join(process.resourcesPath, 'resources', 'wx_key.dll'))
|
||||||
candidates.push(join(process.resourcesPath, 'wx_key.dll'))
|
candidates.push(join(process.resourcesPath, 'wx_key.dll'))
|
||||||
} else {
|
} else {
|
||||||
// 开发环境
|
|
||||||
const cwd = process.cwd()
|
const cwd = process.cwd()
|
||||||
candidates.push(join(cwd, 'resources', 'wx_key.dll'))
|
candidates.push(join(cwd, 'resources', 'wx_key.dll'))
|
||||||
candidates.push(join(app.getAppPath(), 'resources', 'wx_key.dll'))
|
candidates.push(join(app.getAppPath(), 'resources', 'wx_key.dll'))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查并返回第一个存在的路径
|
|
||||||
for (const path of candidates) {
|
for (const path of candidates) {
|
||||||
if (existsSync(path)) {
|
if (existsSync(path)) return path
|
||||||
return path
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果都没找到,返回最可能的路径以便报错信息有参考
|
|
||||||
return candidates[0]
|
return candidates[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查路径是否为 UNC 路径或网络路径
|
|
||||||
private isNetworkPath(path: string): boolean {
|
private isNetworkPath(path: string): boolean {
|
||||||
// UNC 路径以 \\ 开头
|
if (path.startsWith('\\\\')) return true
|
||||||
if (path.startsWith('\\\\')) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// 检查是否为网络映射驱动器(简化检测:A: 表示驱动器)
|
|
||||||
// 注意:这是一个启发式检测,更准确的方式需要调用 GetDriveType Windows API
|
|
||||||
// 但对于大多数 VM 共享场景,UNC 路径检测已足够
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将 DLL 复制到本地临时目录
|
|
||||||
private localizeNetworkDll(originalPath: string): string {
|
private localizeNetworkDll(originalPath: string): string {
|
||||||
try {
|
try {
|
||||||
const tempDir = join(os.tmpdir(), 'weflow_dll_cache')
|
const tempDir = join(os.tmpdir(), 'weflow_dll_cache')
|
||||||
@@ -113,20 +93,12 @@ export class KeyService {
|
|||||||
mkdirSync(tempDir, { recursive: true })
|
mkdirSync(tempDir, { recursive: true })
|
||||||
}
|
}
|
||||||
const localPath = join(tempDir, 'wx_key.dll')
|
const localPath = join(tempDir, 'wx_key.dll')
|
||||||
|
if (existsSync(localPath)) return localPath
|
||||||
// 检查是否已经有本地副本,如果有就使用它
|
|
||||||
if (existsSync(localPath)) {
|
|
||||||
|
|
||||||
return localPath
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
copyFileSync(originalPath, localPath)
|
copyFileSync(originalPath, localPath)
|
||||||
|
|
||||||
return localPath
|
return localPath
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('DLL 本地化失败:', e)
|
console.error('DLL 本地化失败:', e)
|
||||||
// 如果本地化失败,返回原路径
|
|
||||||
return originalPath
|
return originalPath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,9 +116,7 @@ export class KeyService {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否为网络路径,如果是则本地化
|
|
||||||
if (this.isNetworkPath(dllPath)) {
|
if (this.isNetworkPath(dllPath)) {
|
||||||
|
|
||||||
dllPath = this.localizeNetworkDll(dllPath)
|
dllPath = this.localizeNetworkDll(dllPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,18 +126,13 @@ export class KeyService {
|
|||||||
this.getStatusMessage = this.lib.func('bool GetStatusMessage(_Out_ char *msgBuffer, int bufferSize, _Out_ int *outLevel)')
|
this.getStatusMessage = this.lib.func('bool GetStatusMessage(_Out_ char *msgBuffer, int bufferSize, _Out_ int *outLevel)')
|
||||||
this.cleanupHook = this.lib.func('bool CleanupHook()')
|
this.cleanupHook = this.lib.func('bool CleanupHook()')
|
||||||
this.getLastErrorMsg = this.lib.func('const char* GetLastErrorMsg()')
|
this.getLastErrorMsg = this.lib.func('const char* GetLastErrorMsg()')
|
||||||
|
this.getImageKeyDll = this.lib.func('bool GetImageKey(_Out_ char *resultBuffer, int bufferSize)')
|
||||||
|
|
||||||
this.initialized = true
|
this.initialized = true
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const errorMsg = e instanceof Error ? e.message : String(e)
|
const errorMsg = e instanceof Error ? e.message : String(e)
|
||||||
const errorStack = e instanceof Error ? e.stack : ''
|
console.error(`加载 wx_key.dll 失败\n 路径: ${dllPath}\n 错误: ${errorMsg}`)
|
||||||
console.error(`加载 wx_key.dll 失败`)
|
|
||||||
console.error(` 路径: ${dllPath}`)
|
|
||||||
console.error(` 错误: ${errorMsg}`)
|
|
||||||
if (errorStack) {
|
|
||||||
console.error(` 堆栈: ${errorStack}`)
|
|
||||||
}
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,25 +146,10 @@ export class KeyService {
|
|||||||
try {
|
try {
|
||||||
this.koffi = require('koffi')
|
this.koffi = require('koffi')
|
||||||
this.kernel32 = this.koffi.load('kernel32.dll')
|
this.kernel32 = this.koffi.load('kernel32.dll')
|
||||||
|
this.OpenProcess = this.kernel32.func('OpenProcess', 'void*', ['uint32', 'bool', 'uint32'])
|
||||||
const HANDLE = this.koffi.pointer('HANDLE', this.koffi.opaque())
|
this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['void*'])
|
||||||
this.MEMORY_BASIC_INFORMATION = this.koffi.struct('MEMORY_BASIC_INFORMATION', {
|
this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['void*', 'uint32'])
|
||||||
BaseAddress: 'uint64',
|
this.QueryFullProcessImageNameW = this.kernel32.func('QueryFullProcessImageNameW', 'bool', ['void*', 'uint32', this.koffi.out('uint16*'), this.koffi.out('uint32*')])
|
||||||
AllocationBase: 'uint64',
|
|
||||||
AllocationProtect: 'uint32',
|
|
||||||
RegionSize: 'uint64',
|
|
||||||
State: 'uint32',
|
|
||||||
Protect: 'uint32',
|
|
||||||
Type: 'uint32'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Use explicit definitions to avoid parser issues
|
|
||||||
this.OpenProcess = this.kernel32.func('OpenProcess', 'HANDLE', ['uint32', 'bool', 'uint32'])
|
|
||||||
this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['HANDLE'])
|
|
||||||
this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['HANDLE', 'uint32'])
|
|
||||||
this.QueryFullProcessImageNameW = this.kernel32.func('QueryFullProcessImageNameW', 'bool', ['HANDLE', 'uint32', this.koffi.out('uint16*'), this.koffi.out('uint32*')])
|
|
||||||
this.VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'uint64', ['HANDLE', 'uint64', this.koffi.out(this.koffi.pointer(this.MEMORY_BASIC_INFORMATION)), 'uint64'])
|
|
||||||
this.ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['HANDLE', 'uint64', 'void*', 'uint64', this.koffi.out(this.koffi.pointer('uint64'))])
|
|
||||||
|
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -219,15 +169,12 @@ export class KeyService {
|
|||||||
this.koffi = require('koffi')
|
this.koffi = require('koffi')
|
||||||
this.user32 = this.koffi.load('user32.dll')
|
this.user32 = this.koffi.load('user32.dll')
|
||||||
|
|
||||||
// Callbacks
|
|
||||||
// Define the prototype and its pointer type
|
|
||||||
const WNDENUMPROC = this.koffi.proto('bool __stdcall (void *hWnd, intptr_t lParam)')
|
const WNDENUMPROC = this.koffi.proto('bool __stdcall (void *hWnd, intptr_t lParam)')
|
||||||
this.WNDENUMPROC_PTR = this.koffi.pointer(WNDENUMPROC)
|
this.WNDENUMPROC_PTR = this.koffi.pointer(WNDENUMPROC)
|
||||||
|
|
||||||
this.EnumWindows = this.user32.func('EnumWindows', 'bool', [this.WNDENUMPROC_PTR, 'intptr_t'])
|
this.EnumWindows = this.user32.func('EnumWindows', 'bool', [this.WNDENUMPROC_PTR, 'intptr_t'])
|
||||||
this.EnumChildWindows = this.user32.func('EnumChildWindows', 'bool', ['void*', this.WNDENUMPROC_PTR, 'intptr_t'])
|
this.EnumChildWindows = this.user32.func('EnumChildWindows', 'bool', ['void*', this.WNDENUMPROC_PTR, 'intptr_t'])
|
||||||
this.PostMessageW = this.user32.func('PostMessageW', 'bool', ['void*', 'uint32', 'uintptr_t', 'intptr_t'])
|
this.PostMessageW = this.user32.func('PostMessageW', 'bool', ['void*', 'uint32', 'uintptr_t', 'intptr_t'])
|
||||||
|
|
||||||
this.GetWindowTextW = this.user32.func('GetWindowTextW', 'int', ['void*', this.koffi.out('uint16*'), 'int'])
|
this.GetWindowTextW = this.user32.func('GetWindowTextW', 'int', ['void*', this.koffi.out('uint16*'), 'int'])
|
||||||
this.GetWindowTextLengthW = this.user32.func('GetWindowTextLengthW', 'int', ['void*'])
|
this.GetWindowTextLengthW = this.user32.func('GetWindowTextLengthW', 'int', ['void*'])
|
||||||
this.GetClassNameW = this.user32.func('GetClassNameW', 'int', ['void*', this.koffi.out('uint16*'), 'int'])
|
this.GetClassNameW = this.user32.func('GetClassNameW', 'int', ['void*', this.koffi.out('uint16*'), 'int'])
|
||||||
@@ -247,8 +194,6 @@ export class KeyService {
|
|||||||
this.koffi = require('koffi')
|
this.koffi = require('koffi')
|
||||||
this.advapi32 = this.koffi.load('advapi32.dll')
|
this.advapi32 = this.koffi.load('advapi32.dll')
|
||||||
|
|
||||||
// Types
|
|
||||||
// Use intptr_t for HKEY to match system architecture (64-bit safe)
|
|
||||||
const HKEY = this.koffi.alias('HKEY', 'intptr_t')
|
const HKEY = this.koffi.alias('HKEY', 'intptr_t')
|
||||||
const HKEY_PTR = this.koffi.pointer(HKEY)
|
const HKEY_PTR = this.koffi.pointer(HKEY)
|
||||||
|
|
||||||
@@ -274,27 +219,19 @@ export class KeyService {
|
|||||||
|
|
||||||
// --- WeChat Process & Path Finding ---
|
// --- WeChat Process & Path Finding ---
|
||||||
|
|
||||||
// Helper to read simple registry string
|
|
||||||
private readRegistryString(rootKey: number, subKey: string, valueName: string): string | null {
|
private readRegistryString(rootKey: number, subKey: string, valueName: string): string | null {
|
||||||
if (!this.ensureAdvapi32()) return null
|
if (!this.ensureAdvapi32()) return null
|
||||||
|
|
||||||
// Convert strings to UTF-16 buffers
|
|
||||||
const subKeyBuf = Buffer.from(subKey + '\0', 'ucs2')
|
const subKeyBuf = Buffer.from(subKey + '\0', 'ucs2')
|
||||||
const valueNameBuf = valueName ? Buffer.from(valueName + '\0', 'ucs2') : null
|
const valueNameBuf = valueName ? Buffer.from(valueName + '\0', 'ucs2') : null
|
||||||
|
const phkResult = Buffer.alloc(8)
|
||||||
|
|
||||||
const phkResult = Buffer.alloc(8) // Pointer size (64-bit safe)
|
if (this.RegOpenKeyExW(rootKey, subKeyBuf, 0, this.KEY_READ, phkResult) !== this.ERROR_SUCCESS) return null
|
||||||
|
|
||||||
if (this.RegOpenKeyExW(rootKey, subKeyBuf, 0, this.KEY_READ, phkResult) !== this.ERROR_SUCCESS) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const hKey = this.koffi.decode(phkResult, 'uintptr_t')
|
const hKey = this.koffi.decode(phkResult, 'uintptr_t')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const lpcbData = Buffer.alloc(4)
|
const lpcbData = Buffer.alloc(4)
|
||||||
lpcbData.writeUInt32LE(0, 0) // First call to get size? No, RegQueryValueExW expects initialized size or null to get size.
|
lpcbData.writeUInt32LE(0, 0)
|
||||||
// Usually we call it twice or just provide a big buffer.
|
|
||||||
// Let's call twice.
|
|
||||||
|
|
||||||
let ret = this.RegQueryValueExW(hKey, valueNameBuf, null, null, null, lpcbData)
|
let ret = this.RegQueryValueExW(hKey, valueNameBuf, null, null, null, lpcbData)
|
||||||
if (ret !== this.ERROR_SUCCESS) return null
|
if (ret !== this.ERROR_SUCCESS) return null
|
||||||
@@ -306,7 +243,6 @@ export class KeyService {
|
|||||||
ret = this.RegQueryValueExW(hKey, valueNameBuf, null, null, dataBuf, lpcbData)
|
ret = this.RegQueryValueExW(hKey, valueNameBuf, null, null, dataBuf, lpcbData)
|
||||||
if (ret !== this.ERROR_SUCCESS) return null
|
if (ret !== this.ERROR_SUCCESS) return null
|
||||||
|
|
||||||
// Read UTF-16 string (remove null terminator)
|
|
||||||
let str = dataBuf.toString('ucs2')
|
let str = dataBuf.toString('ucs2')
|
||||||
if (str.endsWith('\0')) str = str.slice(0, -1)
|
if (str.endsWith('\0')) str = str.slice(0, -1)
|
||||||
return str
|
return str
|
||||||
@@ -317,7 +253,6 @@ export class KeyService {
|
|||||||
|
|
||||||
private async getProcessExecutablePath(pid: number): Promise<string | null> {
|
private async getProcessExecutablePath(pid: number): Promise<string | null> {
|
||||||
if (!this.ensureKernel32()) return null
|
if (!this.ensureKernel32()) return null
|
||||||
// 0x1000 = PROCESS_QUERY_LIMITED_INFORMATION
|
|
||||||
const hProcess = this.OpenProcess(0x1000, false, pid)
|
const hProcess = this.OpenProcess(0x1000, false, pid)
|
||||||
if (!hProcess) return null
|
if (!hProcess) return null
|
||||||
|
|
||||||
@@ -341,33 +276,21 @@ export class KeyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async findWeChatInstallPath(): Promise<string | null> {
|
private async findWeChatInstallPath(): Promise<string | null> {
|
||||||
// 0. 优先尝试获取正在运行的微信进程路径
|
|
||||||
try {
|
try {
|
||||||
const pid = await this.findWeChatPid()
|
const pid = await this.findWeChatPid()
|
||||||
if (pid) {
|
if (pid) {
|
||||||
const runPath = await this.getProcessExecutablePath(pid)
|
const runPath = await this.getProcessExecutablePath(pid)
|
||||||
if (runPath && existsSync(runPath)) {
|
if (runPath && existsSync(runPath)) return runPath
|
||||||
|
|
||||||
return runPath
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('尝试获取运行中微信路径失败:', e)
|
console.error('尝试获取运行中微信路径失败:', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Registry - Uninstall Keys
|
|
||||||
const uninstallKeys = [
|
const uninstallKeys = [
|
||||||
'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
||||||
'SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'
|
'SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'
|
||||||
]
|
]
|
||||||
const roots = [this.HKEY_LOCAL_MACHINE, this.HKEY_CURRENT_USER]
|
const roots = [this.HKEY_LOCAL_MACHINE, this.HKEY_CURRENT_USER]
|
||||||
|
|
||||||
// NOTE: Scanning subkeys in registry via Koffi is tedious (RegEnumKeyEx).
|
|
||||||
// Simplified strategy: Check common known registry keys first, then fallback to common paths.
|
|
||||||
// wx_key searches *all* subkeys of Uninstall, which is robust but complex to port quickly.
|
|
||||||
// Let's rely on specific Tencent keys first.
|
|
||||||
|
|
||||||
// 2. Tencent specific keys
|
|
||||||
const tencentKeys = [
|
const tencentKeys = [
|
||||||
'Software\\Tencent\\WeChat',
|
'Software\\Tencent\\WeChat',
|
||||||
'Software\\WOW6432Node\\Tencent\\WeChat',
|
'Software\\WOW6432Node\\Tencent\\WeChat',
|
||||||
@@ -382,16 +305,13 @@ export class KeyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Uninstall key exact match (sometimes works)
|
|
||||||
for (const root of roots) {
|
for (const root of roots) {
|
||||||
for (const parent of uninstallKeys) {
|
for (const parent of uninstallKeys) {
|
||||||
// Try WeChat specific subkey
|
|
||||||
const path = this.readRegistryString(root, parent + '\\WeChat', 'InstallLocation')
|
const path = this.readRegistryString(root, parent + '\\WeChat', 'InstallLocation')
|
||||||
if (path && existsSync(join(path, 'Weixin.exe'))) return join(path, 'Weixin.exe')
|
if (path && existsSync(join(path, 'Weixin.exe'))) return join(path, 'Weixin.exe')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Common Paths
|
|
||||||
const drives = ['C', 'D', 'E', 'F']
|
const drives = ['C', 'D', 'E', 'F']
|
||||||
const commonPaths = [
|
const commonPaths = [
|
||||||
'Program Files\\Tencent\\WeChat\\WeChat.exe',
|
'Program Files\\Tencent\\WeChat\\WeChat.exe',
|
||||||
@@ -424,7 +344,6 @@ export class KeyService {
|
|||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`获取进程失败 (${imageName}):`, e)
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -435,7 +354,6 @@ export class KeyService {
|
|||||||
const pid = await this.findPidByImageName(name)
|
const pid = await this.findPidByImageName(name)
|
||||||
if (pid) return pid
|
if (pid) return pid
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackPid = await this.waitForWeChatWindow(5000)
|
const fallbackPid = await this.waitForWeChatWindow(5000)
|
||||||
return fallbackPid ?? null
|
return fallbackPid ?? null
|
||||||
}
|
}
|
||||||
@@ -486,14 +404,11 @@ export class KeyService {
|
|||||||
try {
|
try {
|
||||||
await execFileAsync('taskkill', ['/F', '/T', '/IM', 'Weixin.exe'])
|
await execFileAsync('taskkill', ['/F', '/T', '/IM', 'Weixin.exe'])
|
||||||
await execFileAsync('taskkill', ['/F', '/T', '/IM', 'WeChat.exe'])
|
await execFileAsync('taskkill', ['/F', '/T', '/IM', 'WeChat.exe'])
|
||||||
} catch (e) {
|
} catch (e) { }
|
||||||
// Ignore if not found
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.waitForWeChatExit(5000)
|
return await this.waitForWeChatExit(5000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// --- Window Detection ---
|
// --- Window Detection ---
|
||||||
|
|
||||||
private getWindowTitle(hWnd: any): string {
|
private getWindowTitle(hWnd: any): string {
|
||||||
@@ -574,17 +489,12 @@ export class KeyService {
|
|||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
const normalizedTitle = child.title.replace(/\s+/g, '')
|
const normalizedTitle = child.title.replace(/\s+/g, '')
|
||||||
if (normalizedTitle) {
|
if (normalizedTitle) {
|
||||||
if (readyTexts.some(marker => normalizedTitle.includes(marker))) {
|
if (readyTexts.some(marker => normalizedTitle.includes(marker))) return true
|
||||||
return true
|
|
||||||
}
|
|
||||||
titleMatchCount += 1
|
titleMatchCount += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
const className = child.className
|
const className = child.className
|
||||||
if (className) {
|
if (className) {
|
||||||
if (readyClassMarkers.some(marker => className.includes(marker))) {
|
if (readyClassMarkers.some(marker => className.includes(marker))) return true
|
||||||
return true
|
|
||||||
}
|
|
||||||
if (className.length > 5) {
|
if (className.length > 5) {
|
||||||
classMatchCount += 1
|
classMatchCount += 1
|
||||||
hasValidClassName = true
|
hasValidClassName = true
|
||||||
@@ -630,11 +540,11 @@ export class KeyService {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Main Methods ---
|
// --- DB Key Logic (Unchanged core flow) ---
|
||||||
|
|
||||||
async autoGetDbKey(
|
async autoGetDbKey(
|
||||||
timeoutMs = 60_000,
|
timeoutMs = 60_000,
|
||||||
onStatus?: (message: string, level: number) => void
|
onStatus?: (message: string, level: number) => void
|
||||||
): Promise<DbKeyResult> {
|
): Promise<DbKeyResult> {
|
||||||
if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' }
|
if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' }
|
||||||
if (!this.ensureLoaded()) return { success: false, error: 'wx_key.dll 未加载' }
|
if (!this.ensureLoaded()) return { success: false, error: 'wx_key.dll 未加载' }
|
||||||
@@ -642,7 +552,6 @@ export class KeyService {
|
|||||||
|
|
||||||
const logs: string[] = []
|
const logs: string[] = []
|
||||||
|
|
||||||
// 1. Find Path
|
|
||||||
onStatus?.('正在定位微信安装路径...', 0)
|
onStatus?.('正在定位微信安装路径...', 0)
|
||||||
let wechatPath = await this.findWeChatInstallPath()
|
let wechatPath = await this.findWeChatInstallPath()
|
||||||
if (!wechatPath) {
|
if (!wechatPath) {
|
||||||
@@ -651,7 +560,6 @@ export class KeyService {
|
|||||||
return { success: false, error: err }
|
return { success: false, error: err }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Restart WeChat
|
|
||||||
onStatus?.('正在关闭微信以进行获取...', 0)
|
onStatus?.('正在关闭微信以进行获取...', 0)
|
||||||
const closed = await this.killWeChatProcesses()
|
const closed = await this.killWeChatProcesses()
|
||||||
if (!closed) {
|
if (!closed) {
|
||||||
@@ -660,7 +568,6 @@ export class KeyService {
|
|||||||
return { success: false, error: err }
|
return { success: false, error: err }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Launch
|
|
||||||
onStatus?.('正在启动微信...', 0)
|
onStatus?.('正在启动微信...', 0)
|
||||||
const sub = spawn(wechatPath, {
|
const sub = spawn(wechatPath, {
|
||||||
detached: true,
|
detached: true,
|
||||||
@@ -669,23 +576,18 @@ export class KeyService {
|
|||||||
})
|
})
|
||||||
sub.unref()
|
sub.unref()
|
||||||
|
|
||||||
// 4. Wait for Window & Get PID (Crucial change: discover PID from window)
|
|
||||||
onStatus?.('等待微信界面就绪...', 0)
|
onStatus?.('等待微信界面就绪...', 0)
|
||||||
const pid = await this.waitForWeChatWindow()
|
const pid = await this.waitForWeChatWindow()
|
||||||
if (!pid) {
|
if (!pid) return { success: false, error: '启动微信失败或等待界面就绪超时' }
|
||||||
return { success: false, error: '启动微信失败或等待界面就绪超时' }
|
|
||||||
}
|
|
||||||
|
|
||||||
onStatus?.(`检测到微信窗口 (PID: ${pid}),正在获取...`, 0)
|
onStatus?.(`检测到微信窗口 (PID: ${pid}),正在获取...`, 0)
|
||||||
onStatus?.('正在检测微信界面组件...', 0)
|
onStatus?.('正在检测微信界面组件...', 0)
|
||||||
await this.waitForWeChatWindowComponents(pid, 15000)
|
await this.waitForWeChatWindowComponents(pid, 15000)
|
||||||
|
|
||||||
// 5. Inject
|
|
||||||
const ok = this.initHook(pid)
|
const ok = this.initHook(pid)
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : ''
|
const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : ''
|
||||||
if (error) {
|
if (error) {
|
||||||
// 检测权限不足错误 (NTSTATUS 0xC0000022 = STATUS_ACCESS_DENIED)
|
|
||||||
if (error.includes('0xC0000022') || error.includes('ACCESS_DENIED') || error.includes('打开目标进程失败')) {
|
if (error.includes('0xC0000022') || error.includes('ACCESS_DENIED') || error.includes('打开目标进程失败')) {
|
||||||
const friendlyError = '权限不足:无法访问微信进程。\n\n解决方法:\n1. 右键 WeFlow 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件(如360、火绒等)\n3. 确保微信没有以管理员权限运行'
|
const friendlyError = '权限不足:无法访问微信进程。\n\n解决方法:\n1. 右键 WeFlow 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件(如360、火绒等)\n3. 确保微信没有以管理员权限运行'
|
||||||
return { success: false, error: friendlyError }
|
return { success: false, error: friendlyError }
|
||||||
@@ -695,8 +597,8 @@ export class KeyService {
|
|||||||
const statusBuffer = Buffer.alloc(256)
|
const statusBuffer = Buffer.alloc(256)
|
||||||
const levelOut = [0]
|
const levelOut = [0]
|
||||||
const status = this.getStatusMessage && this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut)
|
const status = this.getStatusMessage && this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut)
|
||||||
? this.decodeUtf8(statusBuffer)
|
? this.decodeUtf8(statusBuffer)
|
||||||
: ''
|
: ''
|
||||||
return { success: false, error: status || '初始化失败' }
|
return { success: false, error: status || '初始化失败' }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -716,9 +618,7 @@ export class KeyService {
|
|||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
const statusBuffer = Buffer.alloc(256)
|
const statusBuffer = Buffer.alloc(256)
|
||||||
const levelOut = [0]
|
const levelOut = [0]
|
||||||
if (!this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut)) {
|
if (!this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut)) break
|
||||||
break
|
|
||||||
}
|
|
||||||
const msg = this.decodeUtf8(statusBuffer)
|
const msg = this.decodeUtf8(statusBuffer)
|
||||||
const level = levelOut[0] ?? 0
|
const level = levelOut[0] ?? 0
|
||||||
if (msg) {
|
if (msg) {
|
||||||
@@ -726,7 +626,6 @@ export class KeyService {
|
|||||||
onStatus?.(msg, level)
|
onStatus?.(msg, level)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 120))
|
await new Promise((resolve) => setTimeout(resolve, 120))
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -738,417 +637,68 @@ export class KeyService {
|
|||||||
return { success: false, error: '获取密钥超时', logs }
|
return { success: false, error: '获取密钥超时', logs }
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Image Key Stuff (Legacy but kept) ---
|
// --- Image Key (通过 DLL 从缓存目录直接获取) ---
|
||||||
|
|
||||||
private isAccountDir(dirPath: string): boolean {
|
|
||||||
return (
|
|
||||||
existsSync(join(dirPath, 'db_storage')) ||
|
|
||||||
existsSync(join(dirPath, 'FileStorage', 'Image')) ||
|
|
||||||
existsSync(join(dirPath, 'FileStorage', 'Image2')) ||
|
|
||||||
existsSync(join(dirPath, 'msg', 'attach'))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private isPotentialAccountName(name: string): boolean {
|
|
||||||
const lower = name.toLowerCase()
|
|
||||||
if (lower.startsWith('all') || lower.startsWith('applet') || lower.startsWith('backup') || lower.startsWith('wmpf')) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (lower.startsWith('wxid_')) return true
|
|
||||||
if (/^\d+$/.test(name) && name.length >= 6) return true
|
|
||||||
return name.length > 5
|
|
||||||
}
|
|
||||||
|
|
||||||
private listAccountDirs(rootDir: string): string[] {
|
|
||||||
try {
|
|
||||||
const entries = readdirSync(rootDir)
|
|
||||||
const candidates: { path: string; mtime: number; isAccount: boolean }[] = []
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = join(rootDir, entry)
|
|
||||||
try {
|
|
||||||
if (!statSync(fullPath).isDirectory()) continue
|
|
||||||
} catch {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.isPotentialAccountName(entry)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAccount = this.isAccountDir(fullPath)
|
|
||||||
candidates.push({
|
|
||||||
path: fullPath,
|
|
||||||
mtime: this.getDirMtime(fullPath),
|
|
||||||
isAccount
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 优先选择有效账号目录,然后按修改时间从新到旧排序
|
|
||||||
return candidates
|
|
||||||
.sort((a, b) => {
|
|
||||||
if (a.isAccount !== b.isAccount) return a.isAccount ? -1 : 1
|
|
||||||
return b.mtime - a.mtime
|
|
||||||
})
|
|
||||||
.map(c => c.path)
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getDirMtime(dirPath: string): number {
|
|
||||||
try {
|
|
||||||
const stat = statSync(dirPath)
|
|
||||||
let mtime = stat.mtimeMs
|
|
||||||
|
|
||||||
// 检查几个关键子目录的修改时间,以更准确地反映活动状态
|
|
||||||
const subDirs = ['db_storage', 'msg/attach', 'FileStorage/Image']
|
|
||||||
for (const sub of subDirs) {
|
|
||||||
const fullPath = join(dirPath, sub)
|
|
||||||
if (existsSync(fullPath)) {
|
|
||||||
try {
|
|
||||||
mtime = Math.max(mtime, statSync(fullPath).mtimeMs)
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return mtime
|
|
||||||
} catch {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizeExistingDir(inputPath: string): string | null {
|
|
||||||
const trimmed = inputPath.replace(/[\\\\/]+$/, '')
|
|
||||||
if (!existsSync(trimmed)) return null
|
|
||||||
try {
|
|
||||||
const stats = statSync(trimmed)
|
|
||||||
if (stats.isFile()) {
|
|
||||||
return dirname(trimmed)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveAccountDirFromPath(inputPath: string): string | null {
|
|
||||||
const normalized = this.normalizeExistingDir(inputPath)
|
|
||||||
if (!normalized) return null
|
|
||||||
|
|
||||||
if (this.isAccountDir(normalized)) return normalized
|
|
||||||
|
|
||||||
const lower = normalized.toLowerCase()
|
|
||||||
if (lower.endsWith('db_storage') || lower.endsWith('filestorage') || lower.endsWith('image') || lower.endsWith('image2')) {
|
|
||||||
const parent = dirname(normalized)
|
|
||||||
if (this.isAccountDir(parent)) return parent
|
|
||||||
const grandParent = dirname(parent)
|
|
||||||
if (this.isAccountDir(grandParent)) return grandParent
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidates = this.listAccountDirs(normalized)
|
|
||||||
if (candidates.length) return candidates[0]
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveAccountDir(manualDir?: string): string | null {
|
|
||||||
if (manualDir) {
|
|
||||||
const resolved = this.resolveAccountDirFromPath(manualDir)
|
|
||||||
if (resolved) return resolved
|
|
||||||
}
|
|
||||||
|
|
||||||
const userProfile = process.env.USERPROFILE
|
|
||||||
if (!userProfile) return null
|
|
||||||
const roots = [
|
|
||||||
join(userProfile, 'Documents', 'xwechat_files'),
|
|
||||||
join(userProfile, 'Documents', 'WeChat Files')
|
|
||||||
]
|
|
||||||
for (const root of roots) {
|
|
||||||
if (!existsSync(root)) continue
|
|
||||||
const candidates = this.listAccountDirs(root)
|
|
||||||
if (candidates.length) return candidates[0]
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private findTemplateDatFiles(rootDir: string): string[] {
|
|
||||||
const files: string[] = []
|
|
||||||
const stack = [rootDir]
|
|
||||||
const maxFiles = 256
|
|
||||||
while (stack.length && files.length < maxFiles) {
|
|
||||||
const dir = stack.pop() as string
|
|
||||||
let entries: string[]
|
|
||||||
try {
|
|
||||||
entries = readdirSync(dir)
|
|
||||||
} catch {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = join(dir, entry)
|
|
||||||
let stats: any
|
|
||||||
try {
|
|
||||||
stats = statSync(fullPath)
|
|
||||||
} catch {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (stats.isDirectory()) {
|
|
||||||
stack.push(fullPath)
|
|
||||||
} else if (entry.endsWith('_t.dat')) {
|
|
||||||
files.push(fullPath)
|
|
||||||
if (files.length >= maxFiles) break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!files.length) return []
|
|
||||||
const dateReg = /(\d{4}-\d{2})/
|
|
||||||
files.sort((a, b) => {
|
|
||||||
const ma = a.match(dateReg)?.[1]
|
|
||||||
const mb = b.match(dateReg)?.[1]
|
|
||||||
if (ma && mb) return mb.localeCompare(ma)
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
return files.slice(0, 128)
|
|
||||||
}
|
|
||||||
|
|
||||||
private getXorKey(templateFiles: string[]): number | null {
|
|
||||||
const counts = new Map<number, number>()
|
|
||||||
const tailSignatures = [
|
|
||||||
Buffer.from([0xFF, 0xD9]),
|
|
||||||
Buffer.from([0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82])
|
|
||||||
]
|
|
||||||
for (const file of templateFiles) {
|
|
||||||
try {
|
|
||||||
const bytes = readFileSync(file)
|
|
||||||
for (const signature of tailSignatures) {
|
|
||||||
if (bytes.length < signature.length) continue
|
|
||||||
const tail = bytes.subarray(bytes.length - signature.length)
|
|
||||||
const xorKey = tail[0] ^ signature[0]
|
|
||||||
let valid = true
|
|
||||||
for (let i = 1; i < signature.length; i++) {
|
|
||||||
if ((tail[i] ^ xorKey) !== signature[i]) {
|
|
||||||
valid = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (valid) {
|
|
||||||
counts.set(xorKey, (counts.get(xorKey) ?? 0) + 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
if (!counts.size) return null
|
|
||||||
let bestKey: number | null = null
|
|
||||||
let bestCount = 0
|
|
||||||
for (const [key, count] of counts) {
|
|
||||||
if (count > bestCount) {
|
|
||||||
bestCount = count
|
|
||||||
bestKey = key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return bestKey
|
|
||||||
}
|
|
||||||
|
|
||||||
private getCiphertextFromTemplate(templateFiles: string[]): Buffer | null {
|
|
||||||
for (const file of templateFiles) {
|
|
||||||
try {
|
|
||||||
const bytes = readFileSync(file)
|
|
||||||
if (bytes.length < 0x1f) continue
|
|
||||||
if (
|
|
||||||
bytes[0] === 0x07 &&
|
|
||||||
bytes[1] === 0x08 &&
|
|
||||||
bytes[2] === 0x56 &&
|
|
||||||
bytes[3] === 0x32 &&
|
|
||||||
bytes[4] === 0x08 &&
|
|
||||||
bytes[5] === 0x07
|
|
||||||
) {
|
|
||||||
return bytes.subarray(0x0f, 0x1f)
|
|
||||||
}
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private isAlphaNumLower(byte: number): boolean {
|
|
||||||
// 只匹配小写字母 a-z 和数字 0-9(AES密钥格式)
|
|
||||||
return (byte >= 0x61 && byte <= 0x7a) || (byte >= 0x30 && byte <= 0x39)
|
|
||||||
}
|
|
||||||
|
|
||||||
private isUtf16LowerKey(buf: Buffer, start: number): boolean {
|
|
||||||
if (start + 64 > buf.length) return false
|
|
||||||
for (let j = 0; j < 32; j++) {
|
|
||||||
const charByte = buf[start + j * 2]
|
|
||||||
const nullByte = buf[start + j * 2 + 1]
|
|
||||||
if (nullByte !== 0x00 || !this.isAlphaNumLower(charByte)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private verifyKey(ciphertext: Buffer, keyBytes: Buffer): boolean {
|
|
||||||
try {
|
|
||||||
const key = keyBytes.subarray(0, 16)
|
|
||||||
const decipher = crypto.createDecipheriv('aes-128-ecb', key, null)
|
|
||||||
decipher.setAutoPadding(false)
|
|
||||||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
|
||||||
const isJpeg = decrypted.length >= 3 && decrypted[0] === 0xff && decrypted[1] === 0xd8 && decrypted[2] === 0xff
|
|
||||||
const isPng = decrypted.length >= 8 &&
|
|
||||||
decrypted[0] === 0x89 &&
|
|
||||||
decrypted[1] === 0x50 &&
|
|
||||||
decrypted[2] === 0x4e &&
|
|
||||||
decrypted[3] === 0x47 &&
|
|
||||||
decrypted[4] === 0x0d &&
|
|
||||||
decrypted[5] === 0x0a &&
|
|
||||||
decrypted[6] === 0x1a &&
|
|
||||||
decrypted[7] === 0x0a
|
|
||||||
return isJpeg || isPng
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getMemoryRegions(hProcess: any): Array<[number, number]> {
|
|
||||||
const regions: Array<[number, number]> = []
|
|
||||||
const MEM_COMMIT = 0x1000
|
|
||||||
const MEM_PRIVATE = 0x20000
|
|
||||||
const PAGE_NOACCESS = 0x01
|
|
||||||
const PAGE_GUARD = 0x100
|
|
||||||
|
|
||||||
let address = 0
|
|
||||||
const maxAddress = 0x7fffffffffff
|
|
||||||
while (address >= 0 && address < maxAddress) {
|
|
||||||
const info: any = {}
|
|
||||||
const result = this.VirtualQueryEx(hProcess, address, info, this.koffi.sizeof(this.MEMORY_BASIC_INFORMATION))
|
|
||||||
if (!result) break
|
|
||||||
|
|
||||||
const state = info.State
|
|
||||||
const protect = info.Protect
|
|
||||||
const type = info.Type
|
|
||||||
const regionSize = Number(info.RegionSize)
|
|
||||||
// 只收集已提交的私有内存(大幅减少扫描区域)
|
|
||||||
if (state === MEM_COMMIT && type === MEM_PRIVATE && (protect & PAGE_NOACCESS) === 0 && (protect & PAGE_GUARD) === 0) {
|
|
||||||
regions.push([Number(info.BaseAddress), regionSize])
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextAddress = address + regionSize
|
|
||||||
if (nextAddress <= address) break
|
|
||||||
address = nextAddress
|
|
||||||
}
|
|
||||||
return regions
|
|
||||||
}
|
|
||||||
|
|
||||||
private readProcessMemory(hProcess: any, address: number, size: number): Buffer | null {
|
|
||||||
const buffer = Buffer.alloc(size)
|
|
||||||
const bytesRead = [0]
|
|
||||||
const ok = this.ReadProcessMemory(hProcess, address, buffer, size, bytesRead)
|
|
||||||
if (!ok || bytesRead[0] === 0) return null
|
|
||||||
return buffer.subarray(0, bytesRead[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getAesKeyFromMemory(
|
|
||||||
pid: number,
|
|
||||||
ciphertext: Buffer,
|
|
||||||
onProgress?: (current: number, total: number, message: string) => void
|
|
||||||
): Promise<string | null> {
|
|
||||||
if (!this.ensureKernel32()) return null
|
|
||||||
const hProcess = this.OpenProcess(this.PROCESS_ALL_ACCESS, false, pid)
|
|
||||||
if (!hProcess) return null
|
|
||||||
|
|
||||||
try {
|
|
||||||
const allRegions = this.getMemoryRegions(hProcess)
|
|
||||||
const totalRegions = allRegions.length
|
|
||||||
let scannedCount = 0
|
|
||||||
let skippedCount = 0
|
|
||||||
|
|
||||||
for (const [baseAddress, regionSize] of allRegions) {
|
|
||||||
// 跳过太大的内存区域(> 100MB)
|
|
||||||
if (regionSize > 100 * 1024 * 1024) {
|
|
||||||
skippedCount++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
scannedCount++
|
|
||||||
if (scannedCount % 10 === 0) {
|
|
||||||
onProgress?.(scannedCount, totalRegions, `正在扫描微信内存... (${scannedCount}/${totalRegions})`)
|
|
||||||
await new Promise(resolve => setImmediate(resolve))
|
|
||||||
}
|
|
||||||
|
|
||||||
const memory = this.readProcessMemory(hProcess, baseAddress, regionSize)
|
|
||||||
if (!memory) continue
|
|
||||||
|
|
||||||
// 直接在原始字节中搜索32字节的小写字母数字序列
|
|
||||||
for (let i = 0; i < memory.length - 34; i++) {
|
|
||||||
// 检查前导字符(不是小写字母或数字)
|
|
||||||
if (this.isAlphaNumLower(memory[i])) continue
|
|
||||||
|
|
||||||
// 检查接下来32个字节是否都是小写字母或数字
|
|
||||||
let valid = true
|
|
||||||
for (let j = 1; j <= 32; j++) {
|
|
||||||
if (!this.isAlphaNumLower(memory[i + j])) {
|
|
||||||
valid = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!valid) continue
|
|
||||||
|
|
||||||
// 检查尾部字符(不是小写字母或数字)
|
|
||||||
if (i + 33 < memory.length && this.isAlphaNumLower(memory[i + 33])) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyBytes = memory.subarray(i + 1, i + 33)
|
|
||||||
if (this.verifyKey(ciphertext, keyBytes)) {
|
|
||||||
return keyBytes.toString('ascii')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
this.CloseHandle(hProcess)
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async autoGetImageKey(
|
async autoGetImageKey(
|
||||||
manualDir?: string,
|
manualDir?: string,
|
||||||
onProgress?: (message: string) => void
|
onProgress?: (message: string) => void
|
||||||
): Promise<ImageKeyResult> {
|
): Promise<ImageKeyResult> {
|
||||||
if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' }
|
if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' }
|
||||||
if (!this.ensureLoaded()) return { success: false, error: 'wx_key.dll 未加载' }
|
if (!this.ensureLoaded()) return { success: false, error: 'wx_key.dll 未加载' }
|
||||||
if (!this.ensureKernel32()) return { success: false, error: '初始化系统 API 失败' }
|
|
||||||
|
|
||||||
onProgress?.('正在定位微信账号目录...')
|
onProgress?.('正在从缓存目录扫描图片密钥...')
|
||||||
const accountDir = this.resolveAccountDir(manualDir)
|
|
||||||
if (!accountDir) return { success: false, error: '未找到微信账号目录' }
|
|
||||||
|
|
||||||
onProgress?.('正在收集模板文件...')
|
const resultBuffer = Buffer.alloc(8192)
|
||||||
const templateFiles = this.findTemplateDatFiles(accountDir)
|
const ok = this.getImageKeyDll(resultBuffer, resultBuffer.length)
|
||||||
if (!templateFiles.length) return { success: false, error: '未找到模板文件' }
|
|
||||||
|
|
||||||
onProgress?.('正在计算 XOR 密钥...')
|
if (!ok) {
|
||||||
const xorKey = this.getXorKey(templateFiles)
|
const errMsg = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : '获取图片密钥失败'
|
||||||
if (xorKey == null) return { success: false, error: '无法计算 XOR 密钥' }
|
return { success: false, error: errMsg }
|
||||||
|
}
|
||||||
|
|
||||||
onProgress?.('正在读取加密模板数据...')
|
const jsonStr = this.decodeUtf8(resultBuffer)
|
||||||
const ciphertext = this.getCiphertextFromTemplate(templateFiles)
|
let parsed: any
|
||||||
if (!ciphertext) return { success: false, error: '无法读取加密模板数据' }
|
try {
|
||||||
|
parsed = JSON.parse(jsonStr)
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: '解析密钥数据失败' }
|
||||||
|
}
|
||||||
|
|
||||||
const pid = await this.findWeChatPid()
|
// 从 manualDir 中提取 wxid 用于精确匹配
|
||||||
if (!pid) return { success: false, error: '未检测到微信进程' }
|
// 前端传入的格式是 dbPath/wxid_xxx_1234,取最后一段目录名再清理后缀
|
||||||
|
let targetWxid: string | null = null
|
||||||
onProgress?.('正在扫描内存获取 AES 密钥...')
|
if (manualDir) {
|
||||||
const aesKey = await this.getAesKeyFromMemory(pid, ciphertext, (current, total, msg) => {
|
const dirName = manualDir.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? ''
|
||||||
onProgress?.(`${msg} (${current}/${total})`)
|
// 与 DLL 的 CleanWxid 逻辑一致:wxid_a_b_c → wxid_a
|
||||||
})
|
const parts = dirName.split('_')
|
||||||
if (!aesKey) {
|
if (parts.length >= 3 && parts[0] === 'wxid') {
|
||||||
return {
|
targetWxid = `${parts[0]}_${parts[1]}`
|
||||||
success: false,
|
} else if (dirName.startsWith('wxid_')) {
|
||||||
error: '未能从内存中获取 AES 密钥,请打开朋友圈图片后重试'
|
targetWxid = dirName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, xorKey, aesKey: aesKey.slice(0, 16) }
|
const accounts: any[] = parsed.accounts ?? []
|
||||||
|
if (!accounts.length) {
|
||||||
|
return { success: false, error: '未找到有效的密钥组合' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先匹配 wxid,找不到则回退到第一个
|
||||||
|
const matchedAccount = targetWxid
|
||||||
|
? (accounts.find((a: any) => a.wxid === targetWxid) ?? accounts[0])
|
||||||
|
: accounts[0]
|
||||||
|
|
||||||
|
if (!matchedAccount?.keys?.length) {
|
||||||
|
return { success: false, error: '未找到有效的密钥组合' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstKey = matchedAccount.keys[0]
|
||||||
|
onProgress?.(`密钥获取成功 (wxid: ${matchedAccount.wxid}, code: ${firstKey.code})`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
xorKey: firstKey.xorKey,
|
||||||
|
aesKey: firstKey.aesKey
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import { readFile, writeFile, mkdir } from 'fs/promises'
|
|||||||
import { basename, join } from 'path'
|
import { basename, join } from 'path'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import { WasmService } from './wasmService'
|
import { WasmService } from './wasmService'
|
||||||
|
import zlib from 'zlib'
|
||||||
|
|
||||||
export interface SnsLivePhoto {
|
export interface SnsLivePhoto {
|
||||||
url: string
|
url: string
|
||||||
@@ -28,6 +29,7 @@ export interface SnsMedia {
|
|||||||
|
|
||||||
export interface SnsPost {
|
export interface SnsPost {
|
||||||
id: string
|
id: string
|
||||||
|
tid?: string // 数据库主键(雪花 ID),用于精确删除
|
||||||
username: string
|
username: string
|
||||||
nickname: string
|
nickname: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
@@ -36,7 +38,7 @@ export interface SnsPost {
|
|||||||
type?: number
|
type?: number
|
||||||
media: SnsMedia[]
|
media: SnsMedia[]
|
||||||
likes: string[]
|
likes: string[]
|
||||||
comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[]
|
comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] }[]
|
||||||
rawXml?: string
|
rawXml?: string
|
||||||
linkTitle?: string
|
linkTitle?: string
|
||||||
linkUrl?: string
|
linkUrl?: string
|
||||||
@@ -122,6 +124,107 @@ const extractVideoKey = (xml: string): string | undefined => {
|
|||||||
return match ? match[1] : undefined
|
return match ? match[1] : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 XML 中解析评论信息(含表情包、回复关系)
|
||||||
|
*/
|
||||||
|
function parseCommentsFromXml(xml: string): { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] }[] {
|
||||||
|
if (!xml) return []
|
||||||
|
|
||||||
|
type CommentItem = {
|
||||||
|
id: string; nickname: string; username?: string; content: string
|
||||||
|
refCommentId: string; refUsername?: string; refNickname?: string
|
||||||
|
emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[]
|
||||||
|
}
|
||||||
|
const comments: CommentItem[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 支持多种标签格式
|
||||||
|
let listMatch = xml.match(/<CommentUserList>([\s\S]*?)<\/CommentUserList>/i)
|
||||||
|
if (!listMatch) listMatch = xml.match(/<commentUserList>([\s\S]*?)<\/commentUserList>/i)
|
||||||
|
if (!listMatch) listMatch = xml.match(/<commentList>([\s\S]*?)<\/commentList>/i)
|
||||||
|
if (!listMatch) listMatch = xml.match(/<comment_user_list>([\s\S]*?)<\/comment_user_list>/i)
|
||||||
|
if (!listMatch) return comments
|
||||||
|
|
||||||
|
const listXml = listMatch[1]
|
||||||
|
const itemRegex = /<(?:CommentUser|commentUser|comment|user_comment)>([\s\S]*?)<\/(?:CommentUser|commentUser|comment|user_comment)>/gi
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
|
||||||
|
while ((m = itemRegex.exec(listXml)) !== null) {
|
||||||
|
const c = m[1]
|
||||||
|
|
||||||
|
const idMatch = c.match(/<(?:cmtid|commentId|comment_id|id)>([^<]*)<\/(?:cmtid|commentId|comment_id|id)>/i)
|
||||||
|
const usernameMatch = c.match(/<username>([^<]*)<\/username>/i)
|
||||||
|
let nicknameMatch = c.match(/<nickname>([^<]*)<\/nickname>/i)
|
||||||
|
if (!nicknameMatch) nicknameMatch = c.match(/<nickName>([^<]*)<\/nickName>/i)
|
||||||
|
const contentMatch = c.match(/<content>([^<]*)<\/content>/i)
|
||||||
|
const refIdMatch = c.match(/<(?:refCommentId|replyCommentId|ref_comment_id)>([^<]*)<\/(?:refCommentId|replyCommentId|ref_comment_id)>/i)
|
||||||
|
const refNickMatch = c.match(/<(?:refNickname|refNickName|replyNickname)>([^<]*)<\/(?:refNickname|refNickName|replyNickname)>/i)
|
||||||
|
const refUserMatch = c.match(/<ref_username>([^<]*)<\/ref_username>/i)
|
||||||
|
|
||||||
|
// 解析表情包
|
||||||
|
const emojis: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] = []
|
||||||
|
const emojiRegex = /<emojiinfo>([\s\S]*?)<\/emojiinfo>/gi
|
||||||
|
let em: RegExpExecArray | null
|
||||||
|
while ((em = emojiRegex.exec(c)) !== null) {
|
||||||
|
const ex = em[1]
|
||||||
|
const externUrl = ex.match(/<extern_url>([^<]*)<\/extern_url>/i)
|
||||||
|
const cdnUrl = ex.match(/<cdn_url>([^<]*)<\/cdn_url>/i)
|
||||||
|
const plainUrl = ex.match(/<url>([^<]*)<\/url>/i)
|
||||||
|
const urlMatch = externUrl || cdnUrl || plainUrl
|
||||||
|
const md5Match = ex.match(/<md5>([^<]*)<\/md5>/i)
|
||||||
|
const wMatch = ex.match(/<width>([^<]*)<\/width>/i)
|
||||||
|
const hMatch = ex.match(/<height>([^<]*)<\/height>/i)
|
||||||
|
const encMatch = ex.match(/<encrypt_url>([^<]*)<\/encrypt_url>/i)
|
||||||
|
const aesMatch = ex.match(/<aes_key>([^<]*)<\/aes_key>/i)
|
||||||
|
|
||||||
|
const url = urlMatch ? urlMatch[1].trim().replace(/&/g, '&') : ''
|
||||||
|
const encryptUrl = encMatch ? encMatch[1].trim().replace(/&/g, '&') : undefined
|
||||||
|
const aesKey = aesMatch ? aesMatch[1].trim() : undefined
|
||||||
|
|
||||||
|
if (url || encryptUrl) {
|
||||||
|
emojis.push({
|
||||||
|
url,
|
||||||
|
md5: md5Match ? md5Match[1].trim() : '',
|
||||||
|
width: wMatch ? parseInt(wMatch[1]) : 0,
|
||||||
|
height: hMatch ? parseInt(hMatch[1]) : 0,
|
||||||
|
encryptUrl,
|
||||||
|
aesKey
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nicknameMatch && (contentMatch || emojis.length > 0)) {
|
||||||
|
const refId = refIdMatch ? refIdMatch[1].trim() : ''
|
||||||
|
comments.push({
|
||||||
|
id: idMatch ? idMatch[1].trim() : `cmt_${Date.now()}_${Math.random()}`,
|
||||||
|
nickname: nicknameMatch[1].trim(),
|
||||||
|
username: usernameMatch ? usernameMatch[1].trim() : undefined,
|
||||||
|
content: contentMatch ? contentMatch[1].trim() : '',
|
||||||
|
refCommentId: refId === '0' ? '' : refId,
|
||||||
|
refUsername: refUserMatch ? refUserMatch[1].trim() : undefined,
|
||||||
|
refNickname: refNickMatch ? refNickMatch[1].trim() : undefined,
|
||||||
|
emojis: emojis.length > 0 ? emojis : undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 二次解析:通过 refUsername 补全 refNickname
|
||||||
|
const userMap = new Map<string, string>()
|
||||||
|
for (const c of comments) {
|
||||||
|
if (c.username && c.nickname) userMap.set(c.username, c.nickname)
|
||||||
|
}
|
||||||
|
for (const c of comments) {
|
||||||
|
if (!c.refNickname && c.refUsername && c.refCommentId) {
|
||||||
|
c.refNickname = userMap.get(c.refUsername)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[SnsService] parseCommentsFromXml 失败:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return comments
|
||||||
|
}
|
||||||
|
|
||||||
class SnsService {
|
class SnsService {
|
||||||
private configService: ConfigService
|
private configService: ConfigService
|
||||||
private contactCache: ContactCacheService
|
private contactCache: ContactCacheService
|
||||||
@@ -132,6 +235,104 @@ class SnsService {
|
|||||||
this.contactCache = new ContactCacheService(this.configService.get('cachePath') as string)
|
this.contactCache = new ContactCacheService(this.configService.get('cachePath') as string)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private parseLikesFromXml(xml: string): string[] {
|
||||||
|
if (!xml) return []
|
||||||
|
const likes: string[] = []
|
||||||
|
try {
|
||||||
|
let likeListMatch = xml.match(/<LikeUserList>([\s\S]*?)<\/LikeUserList>/i)
|
||||||
|
if (!likeListMatch) likeListMatch = xml.match(/<likeUserList>([\s\S]*?)<\/likeUserList>/i)
|
||||||
|
if (!likeListMatch) likeListMatch = xml.match(/<likeList>([\s\S]*?)<\/likeList>/i)
|
||||||
|
if (!likeListMatch) likeListMatch = xml.match(/<like_user_list>([\s\S]*?)<\/like_user_list>/i)
|
||||||
|
if (!likeListMatch) return likes
|
||||||
|
|
||||||
|
const likeUserRegex = /<(?:LikeUser|likeUser|user_comment)>([\s\S]*?)<\/(?:LikeUser|likeUser|user_comment)>/gi
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
while ((m = likeUserRegex.exec(likeListMatch[1])) !== null) {
|
||||||
|
let nick = m[1].match(/<nickname>([^<]*)<\/nickname>/i)
|
||||||
|
if (!nick) nick = m[1].match(/<nickName>([^<]*)<\/nickName>/i)
|
||||||
|
if (nick) likes.push(nick[1].trim())
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[SnsService] 解析点赞失败:', e)
|
||||||
|
}
|
||||||
|
return likes
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseMediaFromXml(xml: string): { media: SnsMedia[]; videoKey?: string } {
|
||||||
|
if (!xml) return { media: [] }
|
||||||
|
const media: SnsMedia[] = []
|
||||||
|
let videoKey: string | undefined
|
||||||
|
try {
|
||||||
|
const encMatch = xml.match(/<enc\s+key="(\d+)"/i)
|
||||||
|
if (encMatch) videoKey = encMatch[1]
|
||||||
|
|
||||||
|
const mediaRegex = /<media>([\s\S]*?)<\/media>/gi
|
||||||
|
let mediaMatch: RegExpExecArray | null
|
||||||
|
while ((mediaMatch = mediaRegex.exec(xml)) !== null) {
|
||||||
|
const mx = mediaMatch[1]
|
||||||
|
const urlMatch = mx.match(/<url[^>]*>([^<]+)<\/url>/i)
|
||||||
|
const urlTagMatch = mx.match(/<url([^>]*)>/i)
|
||||||
|
const thumbMatch = mx.match(/<thumb[^>]*>([^<]+)<\/thumb>/i)
|
||||||
|
const thumbTagMatch = mx.match(/<thumb([^>]*)>/i)
|
||||||
|
|
||||||
|
let urlToken: string | undefined, urlKey: string | undefined
|
||||||
|
let urlMd5: string | undefined, urlEncIdx: string | undefined
|
||||||
|
if (urlTagMatch?.[1]) {
|
||||||
|
const a = urlTagMatch[1]
|
||||||
|
urlToken = a.match(/token="([^"]+)"/i)?.[1]
|
||||||
|
urlKey = a.match(/key="([^"]+)"/i)?.[1]
|
||||||
|
urlMd5 = a.match(/md5="([^"]+)"/i)?.[1]
|
||||||
|
urlEncIdx = a.match(/enc_idx="([^"]+)"/i)?.[1]
|
||||||
|
}
|
||||||
|
let thumbToken: string | undefined, thumbKey: string | undefined, thumbEncIdx: string | undefined
|
||||||
|
if (thumbTagMatch?.[1]) {
|
||||||
|
const a = thumbTagMatch[1]
|
||||||
|
thumbToken = a.match(/token="([^"]+)"/i)?.[1]
|
||||||
|
thumbKey = a.match(/key="([^"]+)"/i)?.[1]
|
||||||
|
thumbEncIdx = a.match(/enc_idx="([^"]+)"/i)?.[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
const item: SnsMedia = {
|
||||||
|
url: urlMatch ? urlMatch[1].trim() : '',
|
||||||
|
thumb: thumbMatch ? thumbMatch[1].trim() : '',
|
||||||
|
token: urlToken || thumbToken,
|
||||||
|
key: urlKey || thumbKey,
|
||||||
|
md5: urlMd5,
|
||||||
|
encIdx: urlEncIdx || thumbEncIdx
|
||||||
|
}
|
||||||
|
|
||||||
|
const livePhotoMatch = mx.match(/<livePhoto>([\s\S]*?)<\/livePhoto>/i)
|
||||||
|
if (livePhotoMatch) {
|
||||||
|
const lx = livePhotoMatch[1]
|
||||||
|
const lpUrl = lx.match(/<url[^>]*>([^<]+)<\/url>/i)
|
||||||
|
const lpUrlTag = lx.match(/<url([^>]*)>/i)
|
||||||
|
const lpThumb = lx.match(/<thumb[^>]*>([^<]+)<\/thumb>/i)
|
||||||
|
const lpThumbTag = lx.match(/<thumb([^>]*)>/i)
|
||||||
|
let lpToken: string | undefined, lpKey: string | undefined, lpEncIdx: string | undefined
|
||||||
|
if (lpUrlTag?.[1]) {
|
||||||
|
const a = lpUrlTag[1]
|
||||||
|
lpToken = a.match(/token="([^"]+)"/i)?.[1]
|
||||||
|
lpKey = a.match(/key="([^"]+)"/i)?.[1]
|
||||||
|
lpEncIdx = a.match(/enc_idx="([^"]+)"/i)?.[1]
|
||||||
|
}
|
||||||
|
if (!lpToken && lpThumbTag?.[1]) lpToken = lpThumbTag[1].match(/token="([^"]+)"/i)?.[1]
|
||||||
|
if (!lpKey && lpThumbTag?.[1]) lpKey = lpThumbTag[1].match(/key="([^"]+)"/i)?.[1]
|
||||||
|
item.livePhoto = {
|
||||||
|
url: lpUrl ? lpUrl[1].trim() : '',
|
||||||
|
thumb: lpThumb ? lpThumb[1].trim() : '',
|
||||||
|
token: lpToken,
|
||||||
|
key: lpKey,
|
||||||
|
encIdx: lpEncIdx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
media.push(item)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[SnsService] 解析媒体 XML 失败:', e)
|
||||||
|
}
|
||||||
|
return { media, videoKey }
|
||||||
|
}
|
||||||
|
|
||||||
private getSnsCacheDir(): string {
|
private getSnsCacheDir(): string {
|
||||||
const cachePath = this.configService.getCacheBasePath()
|
const cachePath = this.configService.getCacheBasePath()
|
||||||
const snsCacheDir = join(cachePath, 'sns_cache')
|
const snsCacheDir = join(cachePath, 'sns_cache')
|
||||||
@@ -147,7 +348,6 @@ class SnsService {
|
|||||||
return join(this.getSnsCacheDir(), `${hash}${ext}`)
|
return join(this.getSnsCacheDir(), `${hash}${ext}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取所有发过朋友圈的用户名列表
|
|
||||||
async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
|
async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
|
||||||
const result = await wcdbService.execQuery('sns', null, 'SELECT DISTINCT user_name FROM SnsTimeLine')
|
const result = await wcdbService.execQuery('sns', null, 'SELECT DISTINCT user_name FROM SnsTimeLine')
|
||||||
if (!result.success || !result.rows) {
|
if (!result.success || !result.rows) {
|
||||||
@@ -159,51 +359,142 @@ class SnsService {
|
|||||||
return { success: true, usernames: result.rows.map((r: any) => r.user_name).filter(Boolean) }
|
return { success: true, usernames: result.rows.map((r: any) => r.user_name).filter(Boolean) }
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTimeline(limit: number = 20, offset: number = 0, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: SnsPost[]; error?: string }> {
|
// 安装朋友圈删除拦截
|
||||||
const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime)
|
async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> {
|
||||||
|
return wcdbService.installSnsBlockDeleteTrigger()
|
||||||
|
}
|
||||||
|
|
||||||
if (result.success && result.timeline) {
|
// 卸载朋友圈删除拦截
|
||||||
const enrichedTimeline = result.timeline.map((post: any) => {
|
async uninstallSnsBlockDeleteTrigger(): Promise<{ success: boolean; error?: string }> {
|
||||||
const contact = this.contactCache.get(post.username)
|
return wcdbService.uninstallSnsBlockDeleteTrigger()
|
||||||
const isVideoPost = post.type === 15
|
}
|
||||||
|
|
||||||
// 尝试从 rawXml 中提取视频解密密钥 (针对视频号视频)
|
// 查询朋友圈删除拦截是否已安装
|
||||||
const videoKey = extractVideoKey(post.rawXml || '')
|
async checkSnsBlockDeleteTrigger(): Promise<{ success: boolean; installed?: boolean; error?: string }> {
|
||||||
|
return wcdbService.checkSnsBlockDeleteTrigger()
|
||||||
|
}
|
||||||
|
|
||||||
const fixedMedia = (post.media || []).map((m: any) => ({
|
// 从数据库直接删除朋友圈记录
|
||||||
// 如果是视频动态,url 是视频,thumb 是缩略图
|
async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> {
|
||||||
url: fixSnsUrl(m.url, m.token, isVideoPost),
|
return wcdbService.deleteSnsPost(postId)
|
||||||
thumb: fixSnsUrl(m.thumb, m.token, false),
|
}
|
||||||
md5: m.md5,
|
|
||||||
token: m.token,
|
/**
|
||||||
// 只有在视频动态 (Type 15) 下才尝试将 XML 提取的 videoKey 赋予主媒体
|
* 补全 DLL 返回的评论中缺失的 refNickname
|
||||||
// 对于图片或实况照片的静态部分,应保留原始 m.key (由 DLL/DB 提供),避免由于错误的 Isaac64 密钥导致图片解密损坏
|
* DLL 返回的 refCommentId 是被回复评论的 cmtid
|
||||||
key: isVideoPost ? (videoKey || m.key) : m.key,
|
* 评论按 cmtid 从小到大排列,cmtid 从 1 开始递增
|
||||||
encIdx: m.encIdx || m.enc_idx,
|
*/
|
||||||
livePhoto: m.livePhoto
|
private fixCommentRefs(comments: any[]): any[] {
|
||||||
? {
|
if (!comments || comments.length === 0) return []
|
||||||
...m.livePhoto,
|
|
||||||
url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token, true),
|
// DLL 现在返回完整的评论数据(含 emojis、refNickname)
|
||||||
thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token, false),
|
// 此处做最终的格式化和兜底补全
|
||||||
token: m.livePhoto.token,
|
const idToNickname = new Map<string, string>()
|
||||||
// 实况照片的视频部分优先使用从 XML 提取的 Key
|
comments.forEach((c, idx) => {
|
||||||
key: videoKey || m.livePhoto.key || m.key,
|
if (c.id) idToNickname.set(c.id, c.nickname || '')
|
||||||
encIdx: m.livePhoto.encIdx || m.livePhoto.enc_idx
|
// 兜底:按索引映射(部分旧数据 id 可能为空)
|
||||||
}
|
idToNickname.set(String(idx + 1), c.nickname || '')
|
||||||
: undefined
|
})
|
||||||
|
|
||||||
|
return comments.map((c) => {
|
||||||
|
const refId = c.refCommentId
|
||||||
|
let refNickname = c.refNickname || ''
|
||||||
|
|
||||||
|
if (refId && refId !== '0' && refId !== '' && !refNickname) {
|
||||||
|
refNickname = idToNickname.get(refId) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 emojis:过滤掉空的 url 和 encryptUrl
|
||||||
|
const emojis = (c.emojis || [])
|
||||||
|
.filter((e: any) => e.url || e.encryptUrl)
|
||||||
|
.map((e: any) => ({
|
||||||
|
url: (e.url || '').replace(/&/g, '&'),
|
||||||
|
md5: e.md5 || '',
|
||||||
|
width: e.width || 0,
|
||||||
|
height: e.height || 0,
|
||||||
|
encryptUrl: e.encryptUrl ? e.encryptUrl.replace(/&/g, '&') : undefined,
|
||||||
|
aesKey: e.aesKey || undefined
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...post,
|
id: c.id || '',
|
||||||
avatarUrl: contact?.avatarUrl,
|
nickname: c.nickname || '',
|
||||||
nickname: post.nickname || contact?.displayName || post.username,
|
content: c.content || '',
|
||||||
media: fixedMedia
|
refCommentId: (refId === '0') ? '' : (refId || ''),
|
||||||
}
|
refNickname,
|
||||||
})
|
emojis: emojis.length > 0 ? emojis : undefined
|
||||||
return { ...result, timeline: enrichedTimeline }
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTimeline(limit: number = 20, offset: number = 0, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: SnsPost[]; error?: string }> {
|
||||||
|
const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime)
|
||||||
|
if (!result.success || !result.timeline || result.timeline.length === 0) return result
|
||||||
|
|
||||||
|
// 诊断:测试 execQuery 查 content 字段
|
||||||
|
try {
|
||||||
|
const testResult = await wcdbService.execQuery('sns', null, 'SELECT tid, CAST(content AS TEXT) as ct, typeof(content) as ctype FROM SnsTimeLine ORDER BY tid DESC LIMIT 1')
|
||||||
|
if (testResult.success && testResult.rows?.[0]) {
|
||||||
|
const r = testResult.rows[0]
|
||||||
|
console.log('[SnsService] execQuery 诊断: ctype=', r.ctype, 'ct长度=', r.ct?.length, 'ct前200=', r.ct?.substring(0, 200))
|
||||||
|
console.log('[SnsService] ct包含CommentUserList:', r.ct?.includes('CommentUserList'))
|
||||||
|
} else {
|
||||||
|
console.log('[SnsService] execQuery 诊断失败:', testResult.error)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[SnsService] execQuery 诊断异常:', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
const enrichedTimeline = result.timeline.map((post: any) => {
|
||||||
|
const contact = this.contactCache.get(post.username)
|
||||||
|
const isVideoPost = post.type === 15
|
||||||
|
const videoKey = extractVideoKey(post.rawXml || '')
|
||||||
|
|
||||||
|
const fixedMedia = (post.media || []).map((m: any) => ({
|
||||||
|
url: fixSnsUrl(m.url, m.token, isVideoPost),
|
||||||
|
thumb: fixSnsUrl(m.thumb, m.token, false),
|
||||||
|
md5: m.md5,
|
||||||
|
token: m.token,
|
||||||
|
key: isVideoPost ? (videoKey || m.key) : m.key,
|
||||||
|
encIdx: m.encIdx || m.enc_idx,
|
||||||
|
livePhoto: m.livePhoto ? {
|
||||||
|
...m.livePhoto,
|
||||||
|
url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token, true),
|
||||||
|
thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token, false),
|
||||||
|
token: m.livePhoto.token,
|
||||||
|
key: videoKey || m.livePhoto.key || m.key,
|
||||||
|
encIdx: m.livePhoto.encIdx || m.livePhoto.enc_idx
|
||||||
|
} : undefined
|
||||||
|
}))
|
||||||
|
|
||||||
|
// DLL 已返回完整评论数据(含 emojis、refNickname)
|
||||||
|
// 如果 DLL 评论缺少表情包信息,回退到从 rawXml 重新解析
|
||||||
|
const dllComments: any[] = post.comments || []
|
||||||
|
const hasEmojisInDll = dllComments.some((c: any) => c.emojis && c.emojis.length > 0)
|
||||||
|
const rawXml = post.rawXml || ''
|
||||||
|
|
||||||
|
let finalComments: any[]
|
||||||
|
if (dllComments.length > 0 && (hasEmojisInDll || !rawXml)) {
|
||||||
|
// DLL 数据完整,直接使用
|
||||||
|
finalComments = this.fixCommentRefs(dllComments)
|
||||||
|
} else if (rawXml) {
|
||||||
|
// 回退:从 rawXml 重新解析(兼容旧版 DLL)
|
||||||
|
const xmlComments = parseCommentsFromXml(rawXml)
|
||||||
|
finalComments = xmlComments.length > 0 ? xmlComments : this.fixCommentRefs(dllComments)
|
||||||
|
} else {
|
||||||
|
finalComments = this.fixCommentRefs(dllComments)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...post,
|
||||||
|
avatarUrl: contact?.avatarUrl,
|
||||||
|
nickname: post.nickname || contact?.displayName || post.username,
|
||||||
|
media: fixedMedia,
|
||||||
|
comments: finalComments
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...result, timeline: enrichedTimeline }
|
||||||
}
|
}
|
||||||
|
|
||||||
async debugResource(url: string): Promise<{ success: boolean; status?: number; headers?: any; error?: string }> {
|
async debugResource(url: string): Promise<{ success: boolean; status?: number; headers?: any; error?: string }> {
|
||||||
@@ -857,6 +1148,316 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 判断 buffer 是否为有效图片头 */
|
||||||
|
private isValidImageBuffer(buf: Buffer): boolean {
|
||||||
|
if (!buf || buf.length < 12) return false
|
||||||
|
if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return true
|
||||||
|
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47) return true
|
||||||
|
if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return true
|
||||||
|
if (buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46
|
||||||
|
&& buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据图片头返回扩展名 */
|
||||||
|
private getImageExtFromBuffer(buf: Buffer): string {
|
||||||
|
if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return '.gif'
|
||||||
|
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47) return '.png'
|
||||||
|
if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return '.jpg'
|
||||||
|
if (buf.length >= 12 && buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46
|
||||||
|
&& buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) return '.webp'
|
||||||
|
return '.gif'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建多种密钥派生方式 */
|
||||||
|
private buildKeyTries(aesKey: string): { name: string; key: Buffer }[] {
|
||||||
|
const keyTries: { name: string; key: Buffer }[] = []
|
||||||
|
const hexStr = aesKey.replace(/\s/g, '')
|
||||||
|
if (hexStr.length >= 32 && /^[0-9a-fA-F]+$/.test(hexStr)) {
|
||||||
|
try {
|
||||||
|
const keyBuf = Buffer.from(hexStr.slice(0, 32), 'hex')
|
||||||
|
if (keyBuf.length === 16) keyTries.push({ name: 'hex-decode', key: keyBuf })
|
||||||
|
} catch { }
|
||||||
|
const rawKey = Buffer.from(hexStr.slice(0, 32), 'utf8')
|
||||||
|
if (rawKey.length === 32) keyTries.push({ name: 'raw-hex-str-32', key: rawKey })
|
||||||
|
}
|
||||||
|
if (aesKey.length >= 16) {
|
||||||
|
keyTries.push({ name: 'utf8-16', key: Buffer.from(aesKey, 'utf8').subarray(0, 16) })
|
||||||
|
}
|
||||||
|
keyTries.push({ name: 'md5', key: crypto.createHash('md5').update(aesKey).digest() })
|
||||||
|
try {
|
||||||
|
const b64Buf = Buffer.from(aesKey, 'base64')
|
||||||
|
if (b64Buf.length >= 16) keyTries.push({ name: 'base64', key: b64Buf.subarray(0, 16) })
|
||||||
|
} catch { }
|
||||||
|
return keyTries
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建多种 GCM 数据布局 */
|
||||||
|
private buildGcmLayouts(encData: Buffer): { nonce: Buffer; ciphertext: Buffer; tag: Buffer }[] {
|
||||||
|
const layouts: { nonce: Buffer; ciphertext: Buffer; tag: Buffer }[] = []
|
||||||
|
// 格式 A:GcmData 块格式
|
||||||
|
if (encData.length > 63 && encData[0] === 0xAB && encData[8] === 0xAB && encData[9] === 0x00) {
|
||||||
|
const payloadSize = encData.readUInt32LE(10)
|
||||||
|
if (payloadSize > 16 && 63 + payloadSize <= encData.length) {
|
||||||
|
const nonce = encData.subarray(19, 31)
|
||||||
|
const payload = encData.subarray(63, 63 + payloadSize)
|
||||||
|
layouts.push({ nonce, ciphertext: payload.subarray(0, payload.length - 16), tag: payload.subarray(payload.length - 16) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 格式 B:尾部 [ciphertext][nonce 12B][tag 16B]
|
||||||
|
if (encData.length > 28) {
|
||||||
|
layouts.push({
|
||||||
|
ciphertext: encData.subarray(0, encData.length - 28),
|
||||||
|
nonce: encData.subarray(encData.length - 28, encData.length - 16),
|
||||||
|
tag: encData.subarray(encData.length - 16)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 格式 C:前置 [nonce 12B][ciphertext][tag 16B]
|
||||||
|
if (encData.length > 28) {
|
||||||
|
layouts.push({
|
||||||
|
nonce: encData.subarray(0, 12),
|
||||||
|
ciphertext: encData.subarray(12, encData.length - 16),
|
||||||
|
tag: encData.subarray(encData.length - 16)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 格式 D:零 nonce
|
||||||
|
if (encData.length > 16) {
|
||||||
|
layouts.push({
|
||||||
|
nonce: Buffer.alloc(12, 0),
|
||||||
|
ciphertext: encData.subarray(0, encData.length - 16),
|
||||||
|
tag: encData.subarray(encData.length - 16)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 格式 E:[nonce 12B][tag 16B][ciphertext]
|
||||||
|
if (encData.length > 28) {
|
||||||
|
layouts.push({
|
||||||
|
nonce: encData.subarray(0, 12),
|
||||||
|
tag: encData.subarray(12, 28),
|
||||||
|
ciphertext: encData.subarray(28)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return layouts
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 尝试 AES-GCM 解密 */
|
||||||
|
private tryGcmDecrypt(key: Buffer, nonce: Buffer, ciphertext: Buffer, tag: Buffer): Buffer | null {
|
||||||
|
try {
|
||||||
|
const algo = key.length === 32 ? 'aes-256-gcm' : 'aes-128-gcm'
|
||||||
|
const decipher = crypto.createDecipheriv(algo, key, nonce)
|
||||||
|
decipher.setAuthTag(tag)
|
||||||
|
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||||
|
if (this.isValidImageBuffer(decrypted)) return decrypted
|
||||||
|
for (const fn of [zlib.inflateSync, zlib.gunzipSync, zlib.unzipSync]) {
|
||||||
|
try {
|
||||||
|
const d = fn(decrypted)
|
||||||
|
if (this.isValidImageBuffer(d)) return d
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
return decrypted
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解密表情数据(多种算法 + 多种密钥派生)
|
||||||
|
* 移植自 ciphertalk 的逆向实现
|
||||||
|
*/
|
||||||
|
private decryptEmojiAes(encData: Buffer, aesKey: string): Buffer | null {
|
||||||
|
if (encData.length <= 16) return null
|
||||||
|
|
||||||
|
const keyTries = this.buildKeyTries(aesKey)
|
||||||
|
const tag = encData.subarray(encData.length - 16)
|
||||||
|
const ciphertext = encData.subarray(0, encData.length - 16)
|
||||||
|
|
||||||
|
// 最高优先级:nonce-tail 格式 [ciphertext][nonce 12B][tag 16B]
|
||||||
|
if (encData.length > 28) {
|
||||||
|
const nonceTail = encData.subarray(encData.length - 28, encData.length - 16)
|
||||||
|
const tagTail = encData.subarray(encData.length - 16)
|
||||||
|
const cipherTail = encData.subarray(0, encData.length - 28)
|
||||||
|
for (const { key } of keyTries) {
|
||||||
|
if (key.length !== 16 && key.length !== 32) continue
|
||||||
|
const result = this.tryGcmDecrypt(key, nonceTail, cipherTail, tagTail)
|
||||||
|
if (result) return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 次优先级:nonce = key 前 12 字节
|
||||||
|
for (const { key } of keyTries) {
|
||||||
|
if (key.length !== 16 && key.length !== 32) continue
|
||||||
|
const nonce = key.subarray(0, 12)
|
||||||
|
const result = this.tryGcmDecrypt(key, nonce, ciphertext, tag)
|
||||||
|
if (result) return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他 GCM 布局
|
||||||
|
const layouts = this.buildGcmLayouts(encData)
|
||||||
|
for (const layout of layouts) {
|
||||||
|
for (const { key } of keyTries) {
|
||||||
|
if (key.length !== 16 && key.length !== 32) continue
|
||||||
|
const result = this.tryGcmDecrypt(key, layout.nonce, layout.ciphertext, layout.tag)
|
||||||
|
if (result) return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退:AES-128-CBC / AES-128-ECB
|
||||||
|
for (const { key } of keyTries) {
|
||||||
|
if (key.length !== 16) continue
|
||||||
|
// CBC:IV = key
|
||||||
|
if (encData.length >= 16 && encData.length % 16 === 0) {
|
||||||
|
try {
|
||||||
|
const dec = crypto.createDecipheriv('aes-128-cbc', key, key)
|
||||||
|
dec.setAutoPadding(true)
|
||||||
|
const result = Buffer.concat([dec.update(encData), dec.final()])
|
||||||
|
if (this.isValidImageBuffer(result)) return result
|
||||||
|
for (const fn of [zlib.inflateSync, zlib.gunzipSync]) {
|
||||||
|
try { const d = fn(result); if (this.isValidImageBuffer(d)) return d } catch { }
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
// CBC:前 16 字节作为 IV
|
||||||
|
if (encData.length > 32) {
|
||||||
|
try {
|
||||||
|
const iv = encData.subarray(0, 16)
|
||||||
|
const dec = crypto.createDecipheriv('aes-128-cbc', key, iv)
|
||||||
|
dec.setAutoPadding(true)
|
||||||
|
const result = Buffer.concat([dec.update(encData.subarray(16)), dec.final()])
|
||||||
|
if (this.isValidImageBuffer(result)) return result
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
// ECB
|
||||||
|
try {
|
||||||
|
const dec = crypto.createDecipheriv('aes-128-ecb', key, null)
|
||||||
|
dec.setAutoPadding(true)
|
||||||
|
const result = Buffer.concat([dec.update(encData), dec.final()])
|
||||||
|
if (this.isValidImageBuffer(result)) return result
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下载原始数据到本地临时文件,支持重定向 */
|
||||||
|
private doDownloadRaw(targetUrl: string, cacheKey: string, cacheDir: string): Promise<string | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
const fs = require('fs')
|
||||||
|
const https = require('https')
|
||||||
|
const http = require('http')
|
||||||
|
let fixedUrl = targetUrl.replace(/&/g, '&')
|
||||||
|
const urlObj = new URL(fixedUrl)
|
||||||
|
const protocol = fixedUrl.startsWith('https') ? https : http
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 MicroMessenger/7.0.20.1781(0x67001431)',
|
||||||
|
'Accept': '*/*',
|
||||||
|
'Connection': 'keep-alive'
|
||||||
|
},
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
timeout: 15000
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = protocol.get(fixedUrl, options, (response: any) => {
|
||||||
|
// 处理重定向
|
||||||
|
if ([301, 302, 303, 307].includes(response.statusCode)) {
|
||||||
|
const redirectUrl = response.headers.location
|
||||||
|
if (redirectUrl) {
|
||||||
|
const full = redirectUrl.startsWith('http') ? redirectUrl : `${urlObj.protocol}//${urlObj.host}${redirectUrl}`
|
||||||
|
this.doDownloadRaw(full, cacheKey, cacheDir).then(resolve)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (response.statusCode !== 200) { resolve(null); return }
|
||||||
|
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
response.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||||
|
response.on('end', () => {
|
||||||
|
const buffer = Buffer.concat(chunks)
|
||||||
|
if (buffer.length === 0) { resolve(null); return }
|
||||||
|
const ext = this.isValidImageBuffer(buffer) ? this.getImageExtFromBuffer(buffer) : '.bin'
|
||||||
|
const filePath = join(cacheDir, `${cacheKey}${ext}`)
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(filePath, buffer)
|
||||||
|
resolve(filePath)
|
||||||
|
} catch { resolve(null) }
|
||||||
|
})
|
||||||
|
response.on('error', () => resolve(null))
|
||||||
|
})
|
||||||
|
request.on('error', () => resolve(null))
|
||||||
|
request.setTimeout(15000, () => { request.destroy(); resolve(null) })
|
||||||
|
} catch { resolve(null) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载朋友圈评论中的表情包(多种解密算法,移植自 ciphertalk)
|
||||||
|
*/
|
||||||
|
async downloadSnsEmoji(url: string, encryptUrl?: string, aesKey?: string): Promise<{ success: boolean; localPath?: string; error?: string }> {
|
||||||
|
if (!url && !encryptUrl) return { success: false, error: 'url 不能为空' }
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const cacheKey = crypto.createHash('md5').update(url || encryptUrl!).digest('hex')
|
||||||
|
const cachePath = this.configService.getCacheBasePath()
|
||||||
|
const emojiDir = join(cachePath, 'sns_emoji_cache')
|
||||||
|
if (!existsSync(emojiDir)) mkdirSync(emojiDir, { recursive: true })
|
||||||
|
|
||||||
|
// 检查本地缓存
|
||||||
|
for (const ext of ['.gif', '.png', '.webp', '.jpg', '.jpeg']) {
|
||||||
|
const filePath = join(emojiDir, `${cacheKey}${ext}`)
|
||||||
|
if (existsSync(filePath)) return { success: true, localPath: filePath }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存解密后的图片
|
||||||
|
const saveDecrypted = (buf: Buffer): { success: boolean; localPath?: string } => {
|
||||||
|
const ext = this.isValidImageBuffer(buf) ? this.getImageExtFromBuffer(buf) : '.gif'
|
||||||
|
const filePath = join(emojiDir, `${cacheKey}${ext}`)
|
||||||
|
try { fs.writeFileSync(filePath, buf); return { success: true, localPath: filePath } }
|
||||||
|
catch { return { success: false } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 优先:encryptUrl + aesKey
|
||||||
|
if (encryptUrl && aesKey) {
|
||||||
|
const encResult = await this.doDownloadRaw(encryptUrl, cacheKey + '_enc', emojiDir)
|
||||||
|
if (encResult) {
|
||||||
|
const encData = fs.readFileSync(encResult)
|
||||||
|
if (this.isValidImageBuffer(encData)) {
|
||||||
|
const ext = this.getImageExtFromBuffer(encData)
|
||||||
|
const filePath = join(emojiDir, `${cacheKey}${ext}`)
|
||||||
|
fs.writeFileSync(filePath, encData)
|
||||||
|
try { fs.unlinkSync(encResult) } catch { }
|
||||||
|
return { success: true, localPath: filePath }
|
||||||
|
}
|
||||||
|
const decrypted = this.decryptEmojiAes(encData, aesKey)
|
||||||
|
if (decrypted) {
|
||||||
|
try { fs.unlinkSync(encResult) } catch { }
|
||||||
|
return saveDecrypted(decrypted)
|
||||||
|
}
|
||||||
|
try { fs.unlinkSync(encResult) } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 直接下载 url
|
||||||
|
if (url) {
|
||||||
|
const result = await this.doDownloadRaw(url, cacheKey, emojiDir)
|
||||||
|
if (result) {
|
||||||
|
const buf = fs.readFileSync(result)
|
||||||
|
if (this.isValidImageBuffer(buf)) return { success: true, localPath: result }
|
||||||
|
// 用 aesKey 解密
|
||||||
|
if (aesKey) {
|
||||||
|
const decrypted = this.decryptEmojiAes(buf, aesKey)
|
||||||
|
if (decrypted) {
|
||||||
|
try { fs.unlinkSync(result) } catch { }
|
||||||
|
return saveDecrypted(decrypted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try { fs.unlinkSync(result) } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: '下载表情包失败' }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const snsService = new SnsService()
|
export const snsService = new SnsService()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs'
|
||||||
|
import { app } from 'electron'
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import Database from 'better-sqlite3'
|
import Database from 'better-sqlite3'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
@@ -18,6 +19,16 @@ class VideoService {
|
|||||||
this.configService = new ConfigService()
|
this.configService = new ConfigService()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private log(message: string, meta?: Record<string, unknown>): void {
|
||||||
|
try {
|
||||||
|
const timestamp = new Date().toISOString()
|
||||||
|
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
|
||||||
|
const logDir = join(app.getPath('userData'), 'logs')
|
||||||
|
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true })
|
||||||
|
appendFileSync(join(logDir, 'wcdb.log'), `[${timestamp}] [VideoService] ${message}${metaStr}\n`, 'utf8')
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取数据库根目录
|
* 获取数据库根目录
|
||||||
*/
|
*/
|
||||||
@@ -36,7 +47,7 @@ class VideoService {
|
|||||||
* 获取缓存目录(解密后的数据库存放位置)
|
* 获取缓存目录(解密后的数据库存放位置)
|
||||||
*/
|
*/
|
||||||
private getCachePath(): string {
|
private getCachePath(): string {
|
||||||
return this.configService.get('cachePath') || ''
|
return this.configService.getCacheBasePath()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,10 +80,12 @@ class VideoService {
|
|||||||
const wxid = this.getMyWxid()
|
const wxid = this.getMyWxid()
|
||||||
const cleanedWxid = this.cleanWxid(wxid)
|
const cleanedWxid = this.cleanWxid(wxid)
|
||||||
|
|
||||||
console.log('[VideoService] queryVideoFileName called with MD5:', md5)
|
this.log('queryVideoFileName 开始', { md5, wxid, cleanedWxid, cachePath, dbPath })
|
||||||
console.log('[VideoService] cachePath:', cachePath, 'dbPath:', dbPath, 'wxid:', wxid, 'cleanedWxid:', cleanedWxid)
|
|
||||||
|
|
||||||
if (!wxid) return undefined
|
if (!wxid) {
|
||||||
|
this.log('queryVideoFileName: wxid 为空')
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
// 方法1:优先在 cachePath 下查找解密后的 hardlink.db
|
// 方法1:优先在 cachePath 下查找解密后的 hardlink.db
|
||||||
if (cachePath) {
|
if (cachePath) {
|
||||||
@@ -86,8 +99,8 @@ class VideoService {
|
|||||||
|
|
||||||
for (const p of cacheDbPaths) {
|
for (const p of cacheDbPaths) {
|
||||||
if (existsSync(p)) {
|
if (existsSync(p)) {
|
||||||
console.log('[VideoService] Found decrypted hardlink.db at:', p)
|
|
||||||
try {
|
try {
|
||||||
|
this.log('尝试缓存 hardlink.db', { path: p })
|
||||||
const db = new Database(p, { readonly: true })
|
const db = new Database(p, { readonly: true })
|
||||||
const row = db.prepare(`
|
const row = db.prepare(`
|
||||||
SELECT file_name, md5 FROM video_hardlink_info_v4
|
SELECT file_name, md5 FROM video_hardlink_info_v4
|
||||||
@@ -98,11 +111,12 @@ class VideoService {
|
|||||||
|
|
||||||
if (row?.file_name) {
|
if (row?.file_name) {
|
||||||
const realMd5 = row.file_name.replace(/\.[^.]+$/, '')
|
const realMd5 = row.file_name.replace(/\.[^.]+$/, '')
|
||||||
console.log('[VideoService] Found video filename via cache:', realMd5)
|
this.log('缓存 hardlink.db 命中', { file_name: row.file_name, realMd5 })
|
||||||
return realMd5
|
return realMd5
|
||||||
}
|
}
|
||||||
|
this.log('缓存 hardlink.db 未命中', { path: p })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('[VideoService] Failed to query cached hardlink.db:', e)
|
this.log('缓存 hardlink.db 查询失败', { path: p, error: String(e) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,41 +124,45 @@ class VideoService {
|
|||||||
|
|
||||||
// 方法2:使用 wcdbService.execQuery 查询加密的 hardlink.db
|
// 方法2:使用 wcdbService.execQuery 查询加密的 hardlink.db
|
||||||
if (dbPath) {
|
if (dbPath) {
|
||||||
const encryptedDbPaths = [
|
const dbPathLower = dbPath.toLowerCase()
|
||||||
join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'),
|
const wxidLower = wxid.toLowerCase()
|
||||||
join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')
|
const cleanedWxidLower = cleanedWxid.toLowerCase()
|
||||||
]
|
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
|
||||||
|
|
||||||
|
const encryptedDbPaths: string[] = []
|
||||||
|
if (dbPathContainsWxid) {
|
||||||
|
encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db'))
|
||||||
|
} else {
|
||||||
|
encryptedDbPaths.push(join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'))
|
||||||
|
encryptedDbPaths.push(join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db'))
|
||||||
|
}
|
||||||
|
|
||||||
for (const p of encryptedDbPaths) {
|
for (const p of encryptedDbPaths) {
|
||||||
if (existsSync(p)) {
|
if (existsSync(p)) {
|
||||||
console.log('[VideoService] Found encrypted hardlink.db at:', p)
|
|
||||||
try {
|
try {
|
||||||
|
this.log('尝试加密 hardlink.db', { path: p })
|
||||||
const escapedMd5 = md5.replace(/'/g, "''")
|
const escapedMd5 = md5.replace(/'/g, "''")
|
||||||
|
|
||||||
// 用 md5 字段查询,获取 file_name
|
|
||||||
const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1`
|
const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1`
|
||||||
console.log('[VideoService] Query SQL:', sql)
|
|
||||||
|
|
||||||
const result = await wcdbService.execQuery('media', p, sql)
|
const result = await wcdbService.execQuery('media', p, sql)
|
||||||
console.log('[VideoService] Query result:', result)
|
|
||||||
|
|
||||||
if (result.success && result.rows && result.rows.length > 0) {
|
if (result.success && result.rows && result.rows.length > 0) {
|
||||||
const row = result.rows[0]
|
const row = result.rows[0]
|
||||||
if (row?.file_name) {
|
if (row?.file_name) {
|
||||||
// 提取不带扩展名的文件名作为实际视频 MD5
|
|
||||||
const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '')
|
const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '')
|
||||||
console.log('[VideoService] Found video filename:', realMd5)
|
this.log('加密 hardlink.db 命中', { file_name: row.file_name, realMd5 })
|
||||||
return realMd5
|
return realMd5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.log('加密 hardlink.db 未命中', { path: p, result: JSON.stringify(result).slice(0, 200) })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('[VideoService] Failed to query encrypted hardlink.db via wcdbService:', e)
|
this.log('加密 hardlink.db 查询失败', { path: p, error: String(e) })
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
this.log('加密 hardlink.db 不存在', { path: p })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.log('queryVideoFileName: 所有方法均未找到', { md5 })
|
||||||
console.log('[VideoService] No matching video found in hardlink.db')
|
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,57 +185,61 @@ class VideoService {
|
|||||||
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
|
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
|
||||||
*/
|
*/
|
||||||
async getVideoInfo(videoMd5: string): Promise<VideoInfo> {
|
async getVideoInfo(videoMd5: string): Promise<VideoInfo> {
|
||||||
console.log('[VideoService] getVideoInfo called with MD5:', videoMd5)
|
|
||||||
|
|
||||||
const dbPath = this.getDbPath()
|
const dbPath = this.getDbPath()
|
||||||
const wxid = this.getMyWxid()
|
const wxid = this.getMyWxid()
|
||||||
|
|
||||||
console.log('[VideoService] Config - dbPath:', dbPath, 'wxid:', wxid)
|
this.log('getVideoInfo 开始', { videoMd5, dbPath, wxid })
|
||||||
|
|
||||||
if (!dbPath || !wxid || !videoMd5) {
|
if (!dbPath || !wxid || !videoMd5) {
|
||||||
console.log('[VideoService] Missing required params')
|
this.log('getVideoInfo: 参数缺失', { dbPath: !!dbPath, wxid: !!wxid, videoMd5: !!videoMd5 })
|
||||||
return { exists: false }
|
return { exists: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 先尝试从数据库查询真正的视频文件名
|
// 先尝试从数据库查询真正的视频文件名
|
||||||
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
|
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
|
||||||
console.log('[VideoService] Real video MD5:', realVideoMd5)
|
this.log('realVideoMd5', { input: videoMd5, resolved: realVideoMd5, changed: realVideoMd5 !== videoMd5 })
|
||||||
|
|
||||||
const videoBaseDir = join(dbPath, wxid, 'msg', 'video')
|
// 检查 dbPath 是否已经包含 wxid,避免重复拼接
|
||||||
console.log('[VideoService] Video base dir:', videoBaseDir)
|
const dbPathLower = dbPath.toLowerCase()
|
||||||
|
const wxidLower = wxid.toLowerCase()
|
||||||
|
const cleanedWxid = this.cleanWxid(wxid)
|
||||||
|
|
||||||
|
let videoBaseDir: string
|
||||||
|
if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) {
|
||||||
|
videoBaseDir = join(dbPath, 'msg', 'video')
|
||||||
|
} else {
|
||||||
|
videoBaseDir = join(dbPath, wxid, 'msg', 'video')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log('videoBaseDir', { videoBaseDir, exists: existsSync(videoBaseDir) })
|
||||||
|
|
||||||
if (!existsSync(videoBaseDir)) {
|
if (!existsSync(videoBaseDir)) {
|
||||||
console.log('[VideoService] Video base dir does not exist')
|
this.log('getVideoInfo: videoBaseDir 不存在')
|
||||||
return { exists: false }
|
return { exists: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 遍历年月目录查找视频文件
|
// 遍历年月目录查找视频文件
|
||||||
try {
|
try {
|
||||||
const allDirs = readdirSync(videoBaseDir)
|
const allDirs = readdirSync(videoBaseDir)
|
||||||
console.log('[VideoService] Found year-month dirs:', allDirs)
|
|
||||||
|
|
||||||
// 支持多种目录格式: YYYY-MM, YYYYMM, 或其他
|
|
||||||
const yearMonthDirs = allDirs
|
const yearMonthDirs = allDirs
|
||||||
.filter(dir => {
|
.filter(dir => {
|
||||||
const dirPath = join(videoBaseDir, dir)
|
const dirPath = join(videoBaseDir, dir)
|
||||||
return statSync(dirPath).isDirectory()
|
return statSync(dirPath).isDirectory()
|
||||||
})
|
})
|
||||||
.sort((a, b) => b.localeCompare(a)) // 从最新的目录开始查找
|
.sort((a, b) => b.localeCompare(a))
|
||||||
|
|
||||||
|
this.log('扫描目录', { dirs: yearMonthDirs })
|
||||||
|
|
||||||
for (const yearMonth of yearMonthDirs) {
|
for (const yearMonth of yearMonthDirs) {
|
||||||
const dirPath = join(videoBaseDir, yearMonth)
|
const dirPath = join(videoBaseDir, yearMonth)
|
||||||
|
|
||||||
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
|
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
|
||||||
const coverPath = join(dirPath, `${realVideoMd5}.jpg`)
|
|
||||||
const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`)
|
|
||||||
|
|
||||||
console.log('[VideoService] Checking:', videoPath)
|
|
||||||
|
|
||||||
// 检查视频文件是否存在
|
|
||||||
if (existsSync(videoPath)) {
|
if (existsSync(videoPath)) {
|
||||||
console.log('[VideoService] Video file found!')
|
this.log('找到视频', { videoPath })
|
||||||
|
const coverPath = join(dirPath, `${realVideoMd5}.jpg`)
|
||||||
|
const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`)
|
||||||
return {
|
return {
|
||||||
videoUrl: videoPath, // 返回文件路径,前端通过 readFile 读取
|
videoUrl: videoPath,
|
||||||
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
|
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
|
||||||
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
|
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
|
||||||
exists: true
|
exists: true
|
||||||
@@ -225,11 +247,17 @@ class VideoService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[VideoService] Video file not found in any directory')
|
// 没找到,列出第一个目录里的文件帮助排查
|
||||||
|
if (yearMonthDirs.length > 0) {
|
||||||
|
const firstDir = join(videoBaseDir, yearMonthDirs[0])
|
||||||
|
const files = readdirSync(firstDir).filter(f => f.endsWith('.mp4')).slice(0, 5)
|
||||||
|
this.log('未找到视频,最新目录样本', { dir: yearMonthDirs[0], sampleFiles: files, lookingFor: `${realVideoMd5}.mp4` })
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[VideoService] Error searching for video:', e)
|
this.log('getVideoInfo 遍历出错', { error: String(e) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.log('getVideoInfo: 未找到视频', { videoMd5, realVideoMd5 })
|
||||||
return { exists: false }
|
return { exists: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,10 +265,8 @@ class VideoService {
|
|||||||
* 根据消息内容解析视频MD5
|
* 根据消息内容解析视频MD5
|
||||||
*/
|
*/
|
||||||
parseVideoMd5(content: string): string | undefined {
|
parseVideoMd5(content: string): string | undefined {
|
||||||
console.log('[VideoService] parseVideoMd5 called, content length:', content?.length)
|
|
||||||
|
|
||||||
// 打印前500字符看看 XML 结构
|
// 打印前500字符看看 XML 结构
|
||||||
console.log('[VideoService] XML preview:', content?.substring(0, 500))
|
|
||||||
|
|
||||||
if (!content) return undefined
|
if (!content) return undefined
|
||||||
|
|
||||||
@@ -252,7 +278,6 @@ class VideoService {
|
|||||||
while ((match = md5Regex.exec(content)) !== null) {
|
while ((match = md5Regex.exec(content)) !== null) {
|
||||||
allMd5s.push(`${match[0]}`)
|
allMd5s.push(`${match[0]}`)
|
||||||
}
|
}
|
||||||
console.log('[VideoService] All MD5 attributes found:', allMd5s)
|
|
||||||
|
|
||||||
// 提取 md5(用于查询 hardlink.db)
|
// 提取 md5(用于查询 hardlink.db)
|
||||||
// 注意:不是 rawmd5,rawmd5 是另一个值
|
// 注意:不是 rawmd5,rawmd5 是另一个值
|
||||||
@@ -261,7 +286,6 @@ class VideoService {
|
|||||||
// 尝试从videomsg标签中提取md5
|
// 尝试从videomsg标签中提取md5
|
||||||
const videoMsgMatch = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
const videoMsgMatch = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||||
if (videoMsgMatch) {
|
if (videoMsgMatch) {
|
||||||
console.log('[VideoService] Found MD5 via videomsg:', videoMsgMatch[1])
|
|
||||||
return videoMsgMatch[1].toLowerCase()
|
return videoMsgMatch[1].toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,11 +297,8 @@ class VideoService {
|
|||||||
|
|
||||||
const md5Match = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
|
const md5Match = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
|
||||||
if (md5Match) {
|
if (md5Match) {
|
||||||
console.log('[VideoService] Found MD5 via <md5> tag:', md5Match[1])
|
|
||||||
return md5Match[1].toLowerCase()
|
return md5Match[1].toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[VideoService] No MD5 found in content')
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[VideoService] 解析视频MD5失败:', e)
|
console.error('[VideoService] 解析视频MD5失败:', e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,48 @@ import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileS
|
|||||||
|
|
||||||
// DLL 初始化错误信息,用于帮助用户诊断问题
|
// DLL 初始化错误信息,用于帮助用户诊断问题
|
||||||
let lastDllInitError: string | null = null
|
let lastDllInitError: string | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 extra_buffer(protobuf)中的免打扰状态
|
||||||
|
* - field 12 (tag 0x60): 值非0 = 免打扰
|
||||||
|
* 折叠状态通过 contact.flag & 0x10000000 判断
|
||||||
|
*/
|
||||||
|
function parseExtraBuffer(raw: Buffer | string | null | undefined): { isMuted: boolean } {
|
||||||
|
if (!raw) return { isMuted: false }
|
||||||
|
// execQuery 返回的 BLOB 列是十六进制字符串,需要先解码
|
||||||
|
const buf: Buffer = typeof raw === 'string' ? Buffer.from(raw, 'hex') : raw
|
||||||
|
if (buf.length === 0) return { isMuted: false }
|
||||||
|
let isMuted = false
|
||||||
|
let i = 0
|
||||||
|
const len = buf.length
|
||||||
|
|
||||||
|
const readVarint = (): number => {
|
||||||
|
let result = 0, shift = 0
|
||||||
|
while (i < len) {
|
||||||
|
const b = buf[i++]
|
||||||
|
result |= (b & 0x7f) << shift
|
||||||
|
shift += 7
|
||||||
|
if (!(b & 0x80)) break
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
while (i < len) {
|
||||||
|
const tag = readVarint()
|
||||||
|
const fieldNum = tag >>> 3
|
||||||
|
const wireType = tag & 0x07
|
||||||
|
if (wireType === 0) {
|
||||||
|
const val = readVarint()
|
||||||
|
if (fieldNum === 12 && val !== 0) isMuted = true
|
||||||
|
} else if (wireType === 2) {
|
||||||
|
const sz = readVarint()
|
||||||
|
i += sz
|
||||||
|
} else if (wireType === 5) { i += 4
|
||||||
|
} else if (wireType === 1) { i += 8
|
||||||
|
} else { break }
|
||||||
|
}
|
||||||
|
return { isMuted }
|
||||||
|
}
|
||||||
export function getLastDllInitError(): string | null {
|
export function getLastDllInitError(): string | null {
|
||||||
return lastDllInitError
|
return lastDllInitError
|
||||||
}
|
}
|
||||||
@@ -41,6 +83,7 @@ export class WcdbCore {
|
|||||||
private wcdbGetMessageTables: any = null
|
private wcdbGetMessageTables: any = null
|
||||||
private wcdbGetMessageMeta: any = null
|
private wcdbGetMessageMeta: any = null
|
||||||
private wcdbGetContact: any = null
|
private wcdbGetContact: any = null
|
||||||
|
private wcdbGetContactStatus: any = null
|
||||||
private wcdbGetMessageTableStats: any = null
|
private wcdbGetMessageTableStats: any = null
|
||||||
private wcdbGetAggregateStats: any = null
|
private wcdbGetAggregateStats: any = null
|
||||||
private wcdbGetAvailableYears: any = null
|
private wcdbGetAvailableYears: any = null
|
||||||
@@ -63,6 +106,10 @@ export class WcdbCore {
|
|||||||
private wcdbGetVoiceData: any = null
|
private wcdbGetVoiceData: any = null
|
||||||
private wcdbGetSnsTimeline: any = null
|
private wcdbGetSnsTimeline: any = null
|
||||||
private wcdbGetSnsAnnualStats: any = null
|
private wcdbGetSnsAnnualStats: any = null
|
||||||
|
private wcdbInstallSnsBlockDeleteTrigger: any = null
|
||||||
|
private wcdbUninstallSnsBlockDeleteTrigger: any = null
|
||||||
|
private wcdbCheckSnsBlockDeleteTrigger: any = null
|
||||||
|
private wcdbDeleteSnsPost: any = null
|
||||||
private wcdbVerifyUser: any = null
|
private wcdbVerifyUser: any = null
|
||||||
private wcdbStartMonitorPipe: any = null
|
private wcdbStartMonitorPipe: any = null
|
||||||
private wcdbStopMonitorPipe: any = null
|
private wcdbStopMonitorPipe: any = null
|
||||||
@@ -483,6 +530,13 @@ export class WcdbCore {
|
|||||||
// wcdb_status wcdb_get_contact(wcdb_handle handle, const char* username, char** out_json)
|
// wcdb_status wcdb_get_contact(wcdb_handle handle, const char* username, char** out_json)
|
||||||
this.wcdbGetContact = this.lib.func('int32 wcdb_get_contact(int64 handle, const char* username, _Out_ void** outJson)')
|
this.wcdbGetContact = this.lib.func('int32 wcdb_get_contact(int64 handle, const char* username, _Out_ void** outJson)')
|
||||||
|
|
||||||
|
// wcdb_status wcdb_get_contact_status(wcdb_handle handle, const char* usernames_json, char** out_json)
|
||||||
|
try {
|
||||||
|
this.wcdbGetContactStatus = this.lib.func('int32 wcdb_get_contact_status(int64 handle, const char* usernamesJson, _Out_ void** outJson)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbGetContactStatus = null
|
||||||
|
}
|
||||||
|
|
||||||
// wcdb_status wcdb_get_message_table_stats(wcdb_handle handle, const char* session_id, char** out_json)
|
// wcdb_status wcdb_get_message_table_stats(wcdb_handle handle, const char* session_id, char** out_json)
|
||||||
this.wcdbGetMessageTableStats = this.lib.func('int32 wcdb_get_message_table_stats(int64 handle, const char* sessionId, _Out_ void** outJson)')
|
this.wcdbGetMessageTableStats = this.lib.func('int32 wcdb_get_message_table_stats(int64 handle, const char* sessionId, _Out_ void** outJson)')
|
||||||
|
|
||||||
@@ -600,6 +654,34 @@ export class WcdbCore {
|
|||||||
this.wcdbGetSnsAnnualStats = null
|
this.wcdbGetSnsAnnualStats = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_install_sns_block_delete_trigger(wcdb_handle handle, char** out_error)
|
||||||
|
try {
|
||||||
|
this.wcdbInstallSnsBlockDeleteTrigger = this.lib.func('int32 wcdb_install_sns_block_delete_trigger(int64 handle, _Out_ void** outError)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbInstallSnsBlockDeleteTrigger = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_uninstall_sns_block_delete_trigger(wcdb_handle handle, char** out_error)
|
||||||
|
try {
|
||||||
|
this.wcdbUninstallSnsBlockDeleteTrigger = this.lib.func('int32 wcdb_uninstall_sns_block_delete_trigger(int64 handle, _Out_ void** outError)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbUninstallSnsBlockDeleteTrigger = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_check_sns_block_delete_trigger(wcdb_handle handle, int32_t* out_installed)
|
||||||
|
try {
|
||||||
|
this.wcdbCheckSnsBlockDeleteTrigger = this.lib.func('int32 wcdb_check_sns_block_delete_trigger(int64 handle, _Out_ int32* outInstalled)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbCheckSnsBlockDeleteTrigger = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_delete_sns_post(wcdb_handle handle, const char* post_id, char** out_error)
|
||||||
|
try {
|
||||||
|
this.wcdbDeleteSnsPost = this.lib.func('int32 wcdb_delete_sns_post(int64 handle, const char* postId, _Out_ void** outError)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbDeleteSnsPost = null
|
||||||
|
}
|
||||||
|
|
||||||
// Named pipe IPC for monitoring (replaces callback)
|
// Named pipe IPC for monitoring (replaces callback)
|
||||||
try {
|
try {
|
||||||
this.wcdbStartMonitorPipe = this.lib.func('int32 wcdb_start_monitor_pipe()')
|
this.wcdbStartMonitorPipe = this.lib.func('int32 wcdb_start_monitor_pipe()')
|
||||||
@@ -1338,6 +1420,36 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getContactStatus(usernames: string[]): Promise<{ success: boolean; map?: Record<string, { isFolded: boolean; isMuted: boolean }>; error?: string }> {
|
||||||
|
if (!this.ensureReady()) {
|
||||||
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// 分批查询,避免 SQL 过长(execQuery 不支持参数绑定,直接拼 SQL)
|
||||||
|
const BATCH = 200
|
||||||
|
const map: Record<string, { isFolded: boolean; isMuted: boolean }> = {}
|
||||||
|
for (let i = 0; i < usernames.length; i += BATCH) {
|
||||||
|
const batch = usernames.slice(i, i + BATCH)
|
||||||
|
const inList = batch.map(u => `'${u.replace(/'/g, "''")}'`).join(',')
|
||||||
|
const sql = `SELECT username, flag, extra_buffer FROM contact WHERE username IN (${inList})`
|
||||||
|
const result = await this.execQuery('contact', null, sql)
|
||||||
|
if (!result.success || !result.rows) continue
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const uname: string = row.username
|
||||||
|
// 折叠:flag bit 28 (0x10000000)
|
||||||
|
const flag = parseInt(row.flag ?? '0', 10)
|
||||||
|
const isFolded = (flag & 0x10000000) !== 0
|
||||||
|
// 免打扰:extra_buffer field 12 非0
|
||||||
|
const { isMuted } = parseExtraBuffer(row.extra_buffer)
|
||||||
|
map[uname] = { isFolded, isMuted }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true, map }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getAggregateStats(sessionIds: string[], beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> {
|
async getAggregateStats(sessionIds: string[], beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||||
if (!this.ensureReady()) {
|
if (!this.ensureReady()) {
|
||||||
return { success: false, error: 'WCDB 未连接' }
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
@@ -1813,6 +1925,94 @@ export class WcdbCore {
|
|||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 为朋友圈安装删除
|
||||||
|
*/
|
||||||
|
async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> {
|
||||||
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
|
if (!this.wcdbInstallSnsBlockDeleteTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' }
|
||||||
|
try {
|
||||||
|
const outPtr = [null]
|
||||||
|
const status = this.wcdbInstallSnsBlockDeleteTrigger(this.handle, outPtr)
|
||||||
|
let msg = ''
|
||||||
|
if (outPtr[0]) {
|
||||||
|
try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { }
|
||||||
|
try { this.wcdbFreeString(outPtr[0]) } catch { }
|
||||||
|
}
|
||||||
|
if (status === 1) {
|
||||||
|
// DLL 返回 1 表示已安装
|
||||||
|
return { success: true, alreadyInstalled: true }
|
||||||
|
}
|
||||||
|
if (status !== 0) {
|
||||||
|
return { success: false, error: msg || `DLL error ${status}` }
|
||||||
|
}
|
||||||
|
return { success: true, alreadyInstalled: false }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭朋友圈删除拦截
|
||||||
|
*/
|
||||||
|
async uninstallSnsBlockDeleteTrigger(): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
|
if (!this.wcdbUninstallSnsBlockDeleteTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' }
|
||||||
|
try {
|
||||||
|
const outPtr = [null]
|
||||||
|
const status = this.wcdbUninstallSnsBlockDeleteTrigger(this.handle, outPtr)
|
||||||
|
let msg = ''
|
||||||
|
if (outPtr[0]) {
|
||||||
|
try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { }
|
||||||
|
try { this.wcdbFreeString(outPtr[0]) } catch { }
|
||||||
|
}
|
||||||
|
if (status !== 0) {
|
||||||
|
return { success: false, error: msg || `DLL error ${status}` }
|
||||||
|
}
|
||||||
|
return { success: true }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询朋友圈删除拦截是否已安装
|
||||||
|
*/
|
||||||
|
async checkSnsBlockDeleteTrigger(): Promise<{ success: boolean; installed?: boolean; error?: string }> {
|
||||||
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
|
if (!this.wcdbCheckSnsBlockDeleteTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' }
|
||||||
|
try {
|
||||||
|
const outInstalled = [0]
|
||||||
|
const status = this.wcdbCheckSnsBlockDeleteTrigger(this.handle, outInstalled)
|
||||||
|
if (status !== 0) {
|
||||||
|
return { success: false, error: `DLL error ${status}` }
|
||||||
|
}
|
||||||
|
return { success: true, installed: outInstalled[0] === 1 }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
|
if (!this.wcdbDeleteSnsPost) return { success: false, error: '当前 DLL 版本不支持此功能' }
|
||||||
|
try {
|
||||||
|
const outPtr = [null]
|
||||||
|
const status = this.wcdbDeleteSnsPost(this.handle, postId, outPtr)
|
||||||
|
let msg = ''
|
||||||
|
if (outPtr[0]) {
|
||||||
|
try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { }
|
||||||
|
try { this.wcdbFreeString(outPtr[0]) } catch { }
|
||||||
|
}
|
||||||
|
if (status !== 0) {
|
||||||
|
return { success: false, error: msg || `DLL error ${status}` }
|
||||||
|
}
|
||||||
|
return { success: true }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getDualReportStats(sessionId: string, beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> {
|
async getDualReportStats(sessionId: string, beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||||
if (!this.ensureReady()) {
|
if (!this.ensureReady()) {
|
||||||
return { success: false, error: 'WCDB 未连接' }
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
|
|||||||
@@ -290,6 +290,13 @@ export class WcdbService {
|
|||||||
return this.callWorker('getContact', { username })
|
return this.callWorker('getContact', { username })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量获取联系人 extra_buffer 状态(isFolded/isMuted)
|
||||||
|
*/
|
||||||
|
async getContactStatus(usernames: string[]): Promise<{ success: boolean; map?: Record<string, { isFolded: boolean; isMuted: boolean }>; error?: string }> {
|
||||||
|
return this.callWorker('getContactStatus', { usernames })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取聚合统计数据
|
* 获取聚合统计数据
|
||||||
*/
|
*/
|
||||||
@@ -416,6 +423,34 @@ export class WcdbService {
|
|||||||
return this.callWorker('getSnsAnnualStats', { beginTimestamp, endTimestamp })
|
return this.callWorker('getSnsAnnualStats', { beginTimestamp, endTimestamp })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安装朋友圈删除拦截
|
||||||
|
*/
|
||||||
|
async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> {
|
||||||
|
return this.callWorker('installSnsBlockDeleteTrigger')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卸载朋友圈删除拦截
|
||||||
|
*/
|
||||||
|
async uninstallSnsBlockDeleteTrigger(): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return this.callWorker('uninstallSnsBlockDeleteTrigger')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询朋友圈删除拦截是否已安装
|
||||||
|
*/
|
||||||
|
async checkSnsBlockDeleteTrigger(): Promise<{ success: boolean; installed?: boolean; error?: string }> {
|
||||||
|
return this.callWorker('checkSnsBlockDeleteTrigger')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从数据库直接删除朋友圈记录
|
||||||
|
*/
|
||||||
|
async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return this.callWorker('deleteSnsPost', { postId })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 DLL 内部日志
|
* 获取 DLL 内部日志
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ if (parentPort) {
|
|||||||
case 'getContact':
|
case 'getContact':
|
||||||
result = await core.getContact(payload.username)
|
result = await core.getContact(payload.username)
|
||||||
break
|
break
|
||||||
|
case 'getContactStatus':
|
||||||
|
result = await core.getContactStatus(payload.usernames)
|
||||||
|
break
|
||||||
case 'getAggregateStats':
|
case 'getAggregateStats':
|
||||||
result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp)
|
result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp)
|
||||||
break
|
break
|
||||||
@@ -144,6 +147,18 @@ if (parentPort) {
|
|||||||
case 'getSnsAnnualStats':
|
case 'getSnsAnnualStats':
|
||||||
result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp)
|
result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp)
|
||||||
break
|
break
|
||||||
|
case 'installSnsBlockDeleteTrigger':
|
||||||
|
result = await core.installSnsBlockDeleteTrigger()
|
||||||
|
break
|
||||||
|
case 'uninstallSnsBlockDeleteTrigger':
|
||||||
|
result = await core.uninstallSnsBlockDeleteTrigger()
|
||||||
|
break
|
||||||
|
case 'checkSnsBlockDeleteTrigger':
|
||||||
|
result = await core.checkSnsBlockDeleteTrigger()
|
||||||
|
break
|
||||||
|
case 'deleteSnsPost':
|
||||||
|
result = await core.deleteSnsPost(payload.postId)
|
||||||
|
break
|
||||||
case 'getLogs':
|
case 'getLogs':
|
||||||
result = await core.getLogs()
|
result = await core.getLogs()
|
||||||
break
|
break
|
||||||
|
|||||||
19
package-lock.json
generated
19
package-lock.json
generated
@@ -80,7 +80,6 @@
|
|||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@@ -2910,7 +2909,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.7.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.7.tgz",
|
||||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -3057,7 +3055,6 @@
|
|||||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
"fast-json-stable-stringify": "^2.0.0",
|
"fast-json-stable-stringify": "^2.0.0",
|
||||||
@@ -3997,7 +3994,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -5107,7 +5103,6 @@
|
|||||||
"integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==",
|
"integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"app-builder-lib": "25.1.8",
|
"app-builder-lib": "25.1.8",
|
||||||
"builder-util": "25.1.7",
|
"builder-util": "25.1.7",
|
||||||
@@ -5295,7 +5290,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz",
|
"resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz",
|
||||||
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
|
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "2.3.0",
|
"tslib": "2.3.0",
|
||||||
"zrender": "5.6.1"
|
"zrender": "5.6.1"
|
||||||
@@ -5382,6 +5376,7 @@
|
|||||||
"integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==",
|
"integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"app-builder-lib": "25.1.8",
|
"app-builder-lib": "25.1.8",
|
||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
@@ -5395,6 +5390,7 @@
|
|||||||
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"graceful-fs": "^4.2.0",
|
"graceful-fs": "^4.2.0",
|
||||||
"jsonfile": "^6.0.1",
|
"jsonfile": "^6.0.1",
|
||||||
@@ -5410,6 +5406,7 @@
|
|||||||
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
|
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"universalify": "^2.0.0"
|
"universalify": "^2.0.0"
|
||||||
},
|
},
|
||||||
@@ -5423,6 +5420,7 @@
|
|||||||
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
}
|
}
|
||||||
@@ -9152,7 +9150,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz",
|
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz",
|
||||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -9162,7 +9159,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -9597,7 +9593,6 @@
|
|||||||
"integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==",
|
"integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^4.0.0",
|
"chokidar": "^4.0.0",
|
||||||
"immutable": "^5.0.2",
|
"immutable": "^5.0.2",
|
||||||
@@ -10439,7 +10434,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -10887,7 +10881,6 @@
|
|||||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
@@ -10977,8 +10970,7 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz",
|
"resolved": "https://registry.npmmirror.com/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz",
|
||||||
"integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==",
|
"integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/fdir": {
|
"node_modules/vite/node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
@@ -11004,7 +10996,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -97,6 +97,10 @@ export function GlobalSessionMonitor() {
|
|||||||
if (!isCurrentSession && (!oldSession || newSession.lastTimestamp > oldSession.lastTimestamp)) {
|
if (!isCurrentSession && (!oldSession || newSession.lastTimestamp > oldSession.lastTimestamp)) {
|
||||||
// 这是新消息事件
|
// 这是新消息事件
|
||||||
|
|
||||||
|
// 免打扰、折叠群、折叠入口不弹通知
|
||||||
|
if (newSession.isMuted || newSession.isFolded) continue
|
||||||
|
if (newSession.username.toLowerCase().includes('placeholder_foldgroup')) continue
|
||||||
|
|
||||||
// 1. 群聊过滤自己发送的消息
|
// 1. 群聊过滤自己发送的消息
|
||||||
if (newSession.username.includes('@chatroom')) {
|
if (newSession.username.includes('@chatroom')) {
|
||||||
// 如果是自己发的消息,不弹通知
|
// 如果是自己发的消息,不弹通知
|
||||||
@@ -253,7 +257,8 @@ export function GlobalSessionMonitor() {
|
|||||||
const handleActiveSessionRefresh = async (sessionId: string) => {
|
const handleActiveSessionRefresh = async (sessionId: string) => {
|
||||||
// 从 ChatPage 复制/调整的逻辑,以保持集中
|
// 从 ChatPage 复制/调整的逻辑,以保持集中
|
||||||
const state = useChatStore.getState()
|
const state = useChatStore.getState()
|
||||||
const lastMsg = state.messages[state.messages.length - 1]
|
const msgs = state.messages || []
|
||||||
|
const lastMsg = msgs[msgs.length - 1]
|
||||||
const minTime = lastMsg?.createTime || 0
|
const minTime = lastMsg?.createTime || 0
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -48,18 +48,26 @@
|
|||||||
backdrop-filter: none !important;
|
backdrop-filter: none !important;
|
||||||
-webkit-backdrop-filter: none !important;
|
-webkit-backdrop-filter: none !important;
|
||||||
|
|
||||||
// 确保背景完全不透明(通知是独立窗口,透明背景会穿透)
|
// 独立通知窗口:默认使用浅色模式硬编码值,确保不依赖 <html> 上的主题属性
|
||||||
background: var(--bg-secondary-solid, var(--bg-secondary, #2c2c2c));
|
background: #ffffff;
|
||||||
color: var(--text-primary, #ffffff);
|
color: #3d3d3d;
|
||||||
|
--text-primary: #3d3d3d;
|
||||||
|
--text-secondary: #666666;
|
||||||
|
--text-tertiary: #999999;
|
||||||
|
--border-light: rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
// 浅色模式强制完全不透明白色背景
|
// 深色模式覆盖
|
||||||
[data-mode="light"] &,
|
[data-mode="dark"] & {
|
||||||
:not([data-mode]) & {
|
background: var(--bg-secondary-solid, #282420);
|
||||||
background: #ffffff !important;
|
color: var(--text-primary, #F0EEE9);
|
||||||
|
--text-primary: #F0EEE9;
|
||||||
|
--text-secondary: #b3b0aa;
|
||||||
|
--text-tertiary: #807d78;
|
||||||
|
--border-light: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
box-shadow: none !important; // NO SHADOW
|
box-shadow: none !important; // NO SHADOW
|
||||||
border: 1px solid var(--border-light, rgba(255, 255, 255, 0.1));
|
border: 1px solid var(--border-light);
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useMemo } from 'react'
|
import React, { useState, useMemo, useEffect } from 'react'
|
||||||
import { Heart, ChevronRight, ImageIcon, Download, Code, MoreHorizontal, Trash2 } from 'lucide-react'
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Heart, ChevronRight, ImageIcon, Code, Trash2 } from 'lucide-react'
|
||||||
import { SnsPost, SnsLinkCardData } from '../../types/sns'
|
import { SnsPost, SnsLinkCardData } from '../../types/sns'
|
||||||
import { Avatar } from '../Avatar'
|
import { Avatar } from '../Avatar'
|
||||||
import { SnsMediaGrid } from './SnsMediaGrid'
|
import { SnsMediaGrid } from './SnsMediaGrid'
|
||||||
@@ -178,14 +179,78 @@ const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 表情包内存缓存
|
||||||
|
const emojiLocalCache = new Map<string, string>()
|
||||||
|
|
||||||
|
// 评论表情包组件
|
||||||
|
const CommentEmoji: React.FC<{
|
||||||
|
emoji: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }
|
||||||
|
onPreview?: (src: string) => void
|
||||||
|
}> = ({ emoji, onPreview }) => {
|
||||||
|
const cacheKey = emoji.encryptUrl || emoji.url
|
||||||
|
const [localSrc, setLocalSrc] = useState<string>(() => emojiLocalCache.get(cacheKey) || '')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cacheKey) return
|
||||||
|
if (emojiLocalCache.has(cacheKey)) {
|
||||||
|
setLocalSrc(emojiLocalCache.get(cacheKey)!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const res = await window.electronAPI.sns.downloadEmoji({
|
||||||
|
url: emoji.url,
|
||||||
|
encryptUrl: emoji.encryptUrl,
|
||||||
|
aesKey: emoji.aesKey
|
||||||
|
})
|
||||||
|
if (cancelled) return
|
||||||
|
if (res.success && res.localPath) {
|
||||||
|
const fileUrl = res.localPath.startsWith('file:')
|
||||||
|
? res.localPath
|
||||||
|
: `file://${res.localPath.replace(/\\/g, '/')}`
|
||||||
|
emojiLocalCache.set(cacheKey, fileUrl)
|
||||||
|
setLocalSrc(fileUrl)
|
||||||
|
}
|
||||||
|
} catch { /* 静默失败 */ }
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [cacheKey])
|
||||||
|
|
||||||
|
if (!localSrc) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={localSrc}
|
||||||
|
alt="emoji"
|
||||||
|
className="comment-custom-emoji"
|
||||||
|
draggable={false}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onPreview?.(localSrc) }}
|
||||||
|
style={{
|
||||||
|
width: Math.min(emoji.width || 24, 30),
|
||||||
|
height: Math.min(emoji.height || 24, 30),
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
marginLeft: 2,
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: onPreview ? 'pointer' : 'default'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface SnsPostItemProps {
|
interface SnsPostItemProps {
|
||||||
post: SnsPost
|
post: SnsPost
|
||||||
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
|
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
|
||||||
onDebug: (post: SnsPost) => void
|
onDebug: (post: SnsPost) => void
|
||||||
|
onDelete?: (postId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug }) => {
|
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug, onDelete }) => {
|
||||||
const [mediaDeleted, setMediaDeleted] = useState(false)
|
const [mediaDeleted, setMediaDeleted] = useState(false)
|
||||||
|
const [dbDeleted, setDbDeleted] = useState(false)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
const linkCard = buildLinkCardData(post)
|
const linkCard = buildLinkCardData(post)
|
||||||
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
|
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
|
||||||
const showLinkCard = Boolean(linkCard) && post.media.length <= 1 && !hasVideoMedia
|
const showLinkCard = Boolean(linkCard) && post.media.length <= 1 && !hasVideoMedia
|
||||||
@@ -221,8 +286,29 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDeleteClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (deleting || dbDeleted) return
|
||||||
|
setShowDeleteConfirm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
setShowDeleteConfirm(false)
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
const r = await window.electronAPI.sns.deleteSnsPost(post.tid ?? post.id)
|
||||||
|
if (r.success) {
|
||||||
|
setDbDeleted(true)
|
||||||
|
onDelete?.(post.id)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`sns-post-item ${mediaDeleted ? 'post-deleted' : ''}`}>
|
<>
|
||||||
|
<div className={`sns-post-item ${(mediaDeleted || dbDeleted) ? 'post-deleted' : ''}`}>
|
||||||
<div className="post-avatar-col">
|
<div className="post-avatar-col">
|
||||||
<Avatar
|
<Avatar
|
||||||
src={post.avatarUrl}
|
src={post.avatarUrl}
|
||||||
@@ -239,12 +325,20 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
|||||||
<span className="post-time">{formatTime(post.createTime)}</span>
|
<span className="post-time">{formatTime(post.createTime)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="post-header-actions">
|
<div className="post-header-actions">
|
||||||
{mediaDeleted && (
|
{(mediaDeleted || dbDeleted) && (
|
||||||
<span className="post-deleted-badge">
|
<span className="post-deleted-badge">
|
||||||
<Trash2 size={12} />
|
<Trash2 size={12} />
|
||||||
<span>已删除</span>
|
<span>已删除</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
className="icon-btn-ghost debug-btn delete-btn"
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
disabled={deleting || dbDeleted}
|
||||||
|
title="从数据库删除此条记录"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
<button className="icon-btn-ghost debug-btn" onClick={(e) => {
|
<button className="icon-btn-ghost debug-btn" onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDebug(post);
|
onDebug(post);
|
||||||
@@ -289,7 +383,16 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<span className="comment-colon">:</span>
|
<span className="comment-colon">:</span>
|
||||||
<span className="comment-content">{renderTextWithEmoji(c.content)}</span>
|
{c.content && (
|
||||||
|
<span className="comment-content">{renderTextWithEmoji(c.content)}</span>
|
||||||
|
)}
|
||||||
|
{c.emojis && c.emojis.map((emoji, ei) => (
|
||||||
|
<CommentEmoji
|
||||||
|
key={ei}
|
||||||
|
emoji={emoji}
|
||||||
|
onPreview={(src) => onPreview(src)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -298,5 +401,24 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 删除确认弹窗 - 用 Portal 挂到 body,避免父级 transform 影响 fixed 定位 */}
|
||||||
|
{showDeleteConfirm && createPortal(
|
||||||
|
<div className="sns-confirm-overlay" onClick={() => setShowDeleteConfirm(false)}>
|
||||||
|
<div className="sns-confirm-dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="sns-confirm-icon">
|
||||||
|
<Trash2 size={22} />
|
||||||
|
</div>
|
||||||
|
<div className="sns-confirm-title">删除这条记录?</div>
|
||||||
|
<div className="sns-confirm-desc">将从本地数据库中永久删除,无法恢复。</div>
|
||||||
|
<div className="sns-confirm-actions">
|
||||||
|
<button className="sns-confirm-cancel" onClick={() => setShowDeleteConfirm(false)}>取消</button>
|
||||||
|
<button className="sns-confirm-ok" onClick={handleDeleteConfirm}>删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -866,6 +866,73 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Header 双 panel 滑动动画
|
||||||
|
.session-header-viewport {
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.session-header-panel {
|
||||||
|
flex: 0 0 100%;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 16px 12px;
|
||||||
|
min-height: 56px;
|
||||||
|
transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-header {
|
||||||
|
transform: translateX(0);
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folded-header {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.folded {
|
||||||
|
.main-header { transform: translateX(-100%); }
|
||||||
|
.folded-header { transform: translateX(-100%); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.folded-view-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.folded-view-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes searchExpand {
|
@keyframes searchExpand {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -3264,9 +3331,12 @@
|
|||||||
// 批量转写模态框基础样式(共享样式在 styles/batchTranscribe.scss)
|
// 批量转写模态框基础样式(共享样式在 styles/batchTranscribe.scss)
|
||||||
|
|
||||||
// 批量转写确认对话框
|
// 批量转写确认对话框
|
||||||
.batch-confirm-modal {
|
.batch-modal-content.batch-confirm-modal {
|
||||||
width: 480px;
|
width: 480px;
|
||||||
max-width: 90vw;
|
max-width: 90vw;
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
overflow-y: visible;
|
||||||
|
|
||||||
.batch-modal-header {
|
.batch-modal-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -3403,6 +3473,74 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.batch-concurrency-field {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.batch-concurrency-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.open svg {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-concurrency-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
min-width: 180px;
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 90%, var(--bg-secondary));
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 6px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-concurrency-option {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3460,7 +3598,7 @@
|
|||||||
&.btn-primary,
|
&.btn-primary,
|
||||||
&.batch-transcribe-start-btn {
|
&.batch-transcribe-start-btn {
|
||||||
background: var(--primary-color);
|
background: var(--primary-color);
|
||||||
color: white;
|
color: #000;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
@@ -3864,3 +4002,134 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 折叠群视图 header
|
||||||
|
.folded-view-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 4px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.folded-view-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 双 panel 滑动容器
|
||||||
|
.session-list-viewport {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
// 两个 panel 并排,宽度各 100%,通过 translateX 切换
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.session-list-panel {
|
||||||
|
flex: 0 0 100%;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认:main 在视口内,folded 在右侧外
|
||||||
|
.main-panel {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
.folded-panel {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换到折叠群视图:两个 panel 同时左移 100%
|
||||||
|
&.folded {
|
||||||
|
.main-panel {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
.folded-panel {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 免打扰标识
|
||||||
|
.session-item {
|
||||||
|
&.muted {
|
||||||
|
.session-name {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-badges {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.mute-icon {
|
||||||
|
color: var(--text-tertiary, #aaa);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unread-badge.muted {
|
||||||
|
background: var(--text-tertiary, #aaa);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 折叠群入口样式
|
||||||
|
.session-item.fold-entry {
|
||||||
|
background: var(--card-inner-bg, rgba(0,0,0,0.03));
|
||||||
|
|
||||||
|
.fold-entry-avatar {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--primary-color, #07c160);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||||
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2 } from 'lucide-react'
|
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed } from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { useChatStore } from '../stores/chatStore'
|
import { useChatStore } from '../stores/chatStore'
|
||||||
@@ -178,15 +178,38 @@ const SessionItem = React.memo(function SessionItem({
|
|||||||
onSelect: (session: ChatSession) => void
|
onSelect: (session: ChatSession) => void
|
||||||
formatTime: (timestamp: number) => string
|
formatTime: (timestamp: number) => string
|
||||||
}) {
|
}) {
|
||||||
// 缓存格式化的时间
|
|
||||||
const timeText = useMemo(() =>
|
const timeText = useMemo(() =>
|
||||||
formatTime(session.lastTimestamp || session.sortTimestamp),
|
formatTime(session.lastTimestamp || session.sortTimestamp),
|
||||||
[formatTime, session.lastTimestamp, session.sortTimestamp]
|
[formatTime, session.lastTimestamp, session.sortTimestamp]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const isFoldEntry = session.username.toLowerCase().includes('placeholder_foldgroup')
|
||||||
|
|
||||||
|
// 折叠入口:专属名称和图标
|
||||||
|
if (isFoldEntry) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`session-item fold-entry`}
|
||||||
|
onClick={() => onSelect(session)}
|
||||||
|
>
|
||||||
|
<div className="fold-entry-avatar">
|
||||||
|
<FolderClosed size={22} />
|
||||||
|
</div>
|
||||||
|
<div className="session-info">
|
||||||
|
<div className="session-top">
|
||||||
|
<span className="session-name">折叠的群聊</span>
|
||||||
|
</div>
|
||||||
|
<div className="session-bottom">
|
||||||
|
<span className="session-summary">{session.summary || ''}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`session-item ${isActive ? 'active' : ''}`}
|
className={`session-item ${isActive ? 'active' : ''} ${session.isMuted ? 'muted' : ''}`}
|
||||||
onClick={() => onSelect(session)}
|
onClick={() => onSelect(session)}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
@@ -202,17 +225,19 @@ const SessionItem = React.memo(function SessionItem({
|
|||||||
</div>
|
</div>
|
||||||
<div className="session-bottom">
|
<div className="session-bottom">
|
||||||
<span className="session-summary">{session.summary || '暂无消息'}</span>
|
<span className="session-summary">{session.summary || '暂无消息'}</span>
|
||||||
{session.unreadCount > 0 && (
|
<div className="session-badges">
|
||||||
<span className="unread-badge">
|
{session.isMuted && <BellOff size={12} className="mute-icon" />}
|
||||||
{session.unreadCount > 99 ? '99+' : session.unreadCount}
|
{session.unreadCount > 0 && (
|
||||||
</span>
|
<span className={`unread-badge ${session.isMuted ? 'muted' : ''}`}>
|
||||||
)}
|
{session.unreadCount > 99 ? '99+' : session.unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}, (prevProps, nextProps) => {
|
}, (prevProps, nextProps) => {
|
||||||
// 自定义比较:只在关键属性变化时重渲染
|
|
||||||
return (
|
return (
|
||||||
prevProps.session.username === nextProps.session.username &&
|
prevProps.session.username === nextProps.session.username &&
|
||||||
prevProps.session.displayName === nextProps.session.displayName &&
|
prevProps.session.displayName === nextProps.session.displayName &&
|
||||||
@@ -221,6 +246,7 @@ const SessionItem = React.memo(function SessionItem({
|
|||||||
prevProps.session.unreadCount === nextProps.session.unreadCount &&
|
prevProps.session.unreadCount === nextProps.session.unreadCount &&
|
||||||
prevProps.session.lastTimestamp === nextProps.session.lastTimestamp &&
|
prevProps.session.lastTimestamp === nextProps.session.lastTimestamp &&
|
||||||
prevProps.session.sortTimestamp === nextProps.session.sortTimestamp &&
|
prevProps.session.sortTimestamp === nextProps.session.sortTimestamp &&
|
||||||
|
prevProps.session.isMuted === nextProps.session.isMuted &&
|
||||||
prevProps.isActive === nextProps.isActive
|
prevProps.isActive === nextProps.isActive
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -288,6 +314,7 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
const [copiedField, setCopiedField] = useState<string | null>(null)
|
const [copiedField, setCopiedField] = useState<string | null>(null)
|
||||||
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
|
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
|
||||||
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false)
|
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false)
|
||||||
|
const [foldedView, setFoldedView] = useState(false) // 是否在"折叠的群聊"视图
|
||||||
const [hasInitialMessages, setHasInitialMessages] = useState(false)
|
const [hasInitialMessages, setHasInitialMessages] = useState(false)
|
||||||
const [noMessageTable, setNoMessageTable] = useState(false)
|
const [noMessageTable, setNoMessageTable] = useState(false)
|
||||||
const [fallbackDisplayName, setFallbackDisplayName] = useState<string | null>(null)
|
const [fallbackDisplayName, setFallbackDisplayName] = useState<string | null>(null)
|
||||||
@@ -318,6 +345,8 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
const [batchImageMessages, setBatchImageMessages] = useState<BatchImageDecryptCandidate[] | null>(null)
|
const [batchImageMessages, setBatchImageMessages] = useState<BatchImageDecryptCandidate[] | null>(null)
|
||||||
const [batchImageDates, setBatchImageDates] = useState<string[]>([])
|
const [batchImageDates, setBatchImageDates] = useState<string[]>([])
|
||||||
const [batchImageSelectedDates, setBatchImageSelectedDates] = useState<Set<string>>(new Set())
|
const [batchImageSelectedDates, setBatchImageSelectedDates] = useState<Set<string>>(new Set())
|
||||||
|
const [batchDecryptConcurrency, setBatchDecryptConcurrency] = useState(6)
|
||||||
|
const [showConcurrencyDropdown, setShowConcurrencyDropdown] = useState(false)
|
||||||
|
|
||||||
// 批量删除相关状态
|
// 批量删除相关状态
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
@@ -738,7 +767,7 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
setIsRefreshingMessages(true)
|
setIsRefreshingMessages(true)
|
||||||
|
|
||||||
// 找出当前已渲染消息中的最大时间戳(使用 getState 获取最新状态,避免闭包过时导致重复)
|
// 找出当前已渲染消息中的最大时间戳(使用 getState 获取最新状态,避免闭包过时导致重复)
|
||||||
const currentMessages = useChatStore.getState().messages
|
const currentMessages = useChatStore.getState().messages || []
|
||||||
const lastMsg = currentMessages[currentMessages.length - 1]
|
const lastMsg = currentMessages[currentMessages.length - 1]
|
||||||
const minTime = lastMsg?.createTime || 0
|
const minTime = lastMsg?.createTime || 0
|
||||||
|
|
||||||
@@ -752,7 +781,7 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
|
|
||||||
if (result.success && result.messages && result.messages.length > 0) {
|
if (result.success && result.messages && result.messages.length > 0) {
|
||||||
// 过滤去重:必须对比实时的状态,防止在 handleRefreshMessages 运行期间导致的冲突
|
// 过滤去重:必须对比实时的状态,防止在 handleRefreshMessages 运行期间导致的冲突
|
||||||
const latestMessages = useChatStore.getState().messages
|
const latestMessages = useChatStore.getState().messages || []
|
||||||
const existingKeys = new Set(latestMessages.map(getMessageKey))
|
const existingKeys = new Set(latestMessages.map(getMessageKey))
|
||||||
const newOnes = result.messages.filter(m => !existingKeys.has(getMessageKey(m)))
|
const newOnes = result.messages.filter(m => !existingKeys.has(getMessageKey(m)))
|
||||||
|
|
||||||
@@ -793,7 +822,7 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 使用实时状态进行去重对比
|
// 使用实时状态进行去重对比
|
||||||
const latestMessages = useChatStore.getState().messages
|
const latestMessages = useChatStore.getState().messages || []
|
||||||
const existing = new Set(latestMessages.map(getMessageKey))
|
const existing = new Set(latestMessages.map(getMessageKey))
|
||||||
const lastMsg = latestMessages[latestMessages.length - 1]
|
const lastMsg = latestMessages[latestMessages.length - 1]
|
||||||
const lastTime = lastMsg?.createTime ?? 0
|
const lastTime = lastMsg?.createTime ?? 0
|
||||||
@@ -995,6 +1024,11 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
|
|
||||||
// 选择会话
|
// 选择会话
|
||||||
const handleSelectSession = (session: ChatSession) => {
|
const handleSelectSession = (session: ChatSession) => {
|
||||||
|
// 点击折叠群入口,切换到折叠群视图
|
||||||
|
if (session.username.toLowerCase().includes('placeholder_foldgroup')) {
|
||||||
|
setFoldedView(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (session.username === currentSessionId) return
|
if (session.username === currentSessionId) return
|
||||||
setCurrentSession(session.username)
|
setCurrentSession(session.username)
|
||||||
setCurrentOffset(0)
|
setCurrentOffset(0)
|
||||||
@@ -1011,27 +1045,11 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
// 搜索过滤
|
// 搜索过滤
|
||||||
const handleSearch = (keyword: string) => {
|
const handleSearch = (keyword: string) => {
|
||||||
setSearchKeyword(keyword)
|
setSearchKeyword(keyword)
|
||||||
if (!Array.isArray(sessions)) {
|
|
||||||
setFilteredSessions([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!keyword.trim()) {
|
|
||||||
setFilteredSessions(sessions)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const lower = keyword.toLowerCase()
|
|
||||||
const filtered = sessions.filter(s =>
|
|
||||||
s.displayName?.toLowerCase().includes(lower) ||
|
|
||||||
s.username.toLowerCase().includes(lower) ||
|
|
||||||
s.summary.toLowerCase().includes(lower)
|
|
||||||
)
|
|
||||||
setFilteredSessions(filtered)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭搜索框
|
// 关闭搜索框
|
||||||
const handleCloseSearch = () => {
|
const handleCloseSearch = () => {
|
||||||
setSearchKeyword('')
|
setSearchKeyword('')
|
||||||
setFilteredSessions(Array.isArray(sessions) ? sessions : [])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 滚动加载更多 + 显示/隐藏回到底部按钮(优化:节流,避免频繁执行)
|
// 滚动加载更多 + 显示/隐藏回到底部按钮(优化:节流,避免频繁执行)
|
||||||
@@ -1303,23 +1321,40 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
searchKeywordRef.current = searchKeyword
|
searchKeywordRef.current = searchKeyword
|
||||||
}, [searchKeyword])
|
}, [searchKeyword])
|
||||||
|
|
||||||
|
// 普通视图:隐藏 isFolded 的群,保留 placeholder_foldgroup 入口
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!Array.isArray(sessions)) {
|
if (!Array.isArray(sessions)) {
|
||||||
setFilteredSessions([])
|
setFilteredSessions([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const visible = sessions.filter(s => {
|
||||||
|
if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
if (!searchKeyword.trim()) {
|
if (!searchKeyword.trim()) {
|
||||||
setFilteredSessions(sessions)
|
setFilteredSessions(visible)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const lower = searchKeyword.toLowerCase()
|
const lower = searchKeyword.toLowerCase()
|
||||||
const filtered = sessions.filter(s =>
|
setFilteredSessions(visible.filter(s =>
|
||||||
|
s.displayName?.toLowerCase().includes(lower) ||
|
||||||
|
s.username.toLowerCase().includes(lower) ||
|
||||||
|
s.summary.toLowerCase().includes(lower)
|
||||||
|
))
|
||||||
|
}, [sessions, searchKeyword, setFilteredSessions])
|
||||||
|
|
||||||
|
// 折叠群列表(独立计算,供折叠 panel 使用)
|
||||||
|
const foldedSessions = useMemo(() => {
|
||||||
|
if (!Array.isArray(sessions)) return []
|
||||||
|
const folded = sessions.filter(s => s.isFolded)
|
||||||
|
if (!searchKeyword.trim() || !foldedView) return folded
|
||||||
|
const lower = searchKeyword.toLowerCase()
|
||||||
|
return folded.filter(s =>
|
||||||
s.displayName?.toLowerCase().includes(lower) ||
|
s.displayName?.toLowerCase().includes(lower) ||
|
||||||
s.username.toLowerCase().includes(lower) ||
|
s.username.toLowerCase().includes(lower) ||
|
||||||
s.summary.toLowerCase().includes(lower)
|
s.summary.toLowerCase().includes(lower)
|
||||||
)
|
)
|
||||||
setFilteredSessions(filtered)
|
}, [sessions, searchKeyword, foldedView])
|
||||||
}, [sessions, searchKeyword, setFilteredSessions])
|
|
||||||
|
|
||||||
|
|
||||||
// 格式化会话时间(相对时间)- 使用 useMemo 缓存,避免每次渲染都计算
|
// 格式化会话时间(相对时间)- 使用 useMemo 缓存,避免每次渲染都计算
|
||||||
@@ -1629,29 +1664,44 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
|
|
||||||
let successCount = 0
|
let successCount = 0
|
||||||
let failCount = 0
|
let failCount = 0
|
||||||
for (let i = 0; i < images.length; i++) {
|
let completed = 0
|
||||||
const img = images[i]
|
const concurrency = batchDecryptConcurrency
|
||||||
|
|
||||||
|
const decryptOne = async (img: typeof images[0]) => {
|
||||||
try {
|
try {
|
||||||
const r = await window.electronAPI.image.decrypt({
|
const r = await window.electronAPI.image.decrypt({
|
||||||
sessionId: session.username,
|
sessionId: session.username,
|
||||||
imageMd5: img.imageMd5,
|
imageMd5: img.imageMd5,
|
||||||
imageDatName: img.imageDatName,
|
imageDatName: img.imageDatName,
|
||||||
force: false
|
force: true
|
||||||
})
|
})
|
||||||
if (r?.success) successCount++
|
if (r?.success) successCount++
|
||||||
else failCount++
|
else failCount++
|
||||||
} catch {
|
} catch {
|
||||||
failCount++
|
failCount++
|
||||||
}
|
}
|
||||||
|
completed++
|
||||||
updateDecryptProgress(i + 1, images.length)
|
updateDecryptProgress(completed, images.length)
|
||||||
if (i % 5 === 0) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 0))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 并发池:同时跑 concurrency 个任务
|
||||||
|
const pool: Promise<void>[] = []
|
||||||
|
for (const img of images) {
|
||||||
|
const p = decryptOne(img)
|
||||||
|
pool.push(p)
|
||||||
|
if (pool.length >= concurrency) {
|
||||||
|
await Promise.race(pool)
|
||||||
|
// 移除已完成的
|
||||||
|
for (let j = pool.length - 1; j >= 0; j--) {
|
||||||
|
const settled = await Promise.race([pool[j].then(() => true), Promise.resolve(false)])
|
||||||
|
if (settled) pool.splice(j, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(pool)
|
||||||
|
|
||||||
finishDecrypt(successCount, failCount)
|
finishDecrypt(successCount, failCount)
|
||||||
}, [batchImageMessages, batchImageSelectedDates, currentSessionId, finishDecrypt, sessions, startDecrypt, updateDecryptProgress])
|
}, [batchImageMessages, batchImageSelectedDates, batchDecryptConcurrency, currentSessionId, finishDecrypt, sessions, startDecrypt, updateDecryptProgress])
|
||||||
|
|
||||||
const batchImageCountByDate = useMemo(() => {
|
const batchImageCountByDate = useMemo(() => {
|
||||||
const map = new Map<string, number>()
|
const map = new Map<string, number>()
|
||||||
@@ -1690,7 +1740,7 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
|
|
||||||
// Range selection with Shift key
|
// Range selection with Shift key
|
||||||
if (isShiftKey && lastSelectedIdRef.current !== null && lastSelectedIdRef.current !== localId) {
|
if (isShiftKey && lastSelectedIdRef.current !== null && lastSelectedIdRef.current !== localId) {
|
||||||
const currentMsgs = useChatStore.getState().messages
|
const currentMsgs = useChatStore.getState().messages || []
|
||||||
const idx1 = currentMsgs.findIndex(m => m.localId === lastSelectedIdRef.current)
|
const idx1 = currentMsgs.findIndex(m => m.localId === lastSelectedIdRef.current)
|
||||||
const idx2 = currentMsgs.findIndex(m => m.localId === localId)
|
const idx2 = currentMsgs.findIndex(m => m.localId === localId)
|
||||||
|
|
||||||
@@ -1760,7 +1810,7 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
const dbPathHint = (msg as any)._db_path
|
const dbPathHint = (msg as any)._db_path
|
||||||
const result = await (window as any).electronAPI.chat.deleteMessage(currentSessionId, msg.localId, msg.createTime, dbPathHint)
|
const result = await (window as any).electronAPI.chat.deleteMessage(currentSessionId, msg.localId, msg.createTime, dbPathHint)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const currentMessages = useChatStore.getState().messages
|
const currentMessages = useChatStore.getState().messages || []
|
||||||
const newMessages = currentMessages.filter(m => m.localId !== msg.localId)
|
const newMessages = currentMessages.filter(m => m.localId !== msg.localId)
|
||||||
useChatStore.getState().setMessages(newMessages)
|
useChatStore.getState().setMessages(newMessages)
|
||||||
} else {
|
} else {
|
||||||
@@ -1821,7 +1871,7 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
try {
|
try {
|
||||||
const result = await (window as any).electronAPI.chat.updateMessage(currentSessionId, editingMessage.message.localId, editingMessage.message.createTime, finalContent)
|
const result = await (window as any).electronAPI.chat.updateMessage(currentSessionId, editingMessage.message.localId, editingMessage.message.createTime, finalContent)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const currentMessages = useChatStore.getState().messages
|
const currentMessages = useChatStore.getState().messages || []
|
||||||
const newMessages = currentMessages.map(m => {
|
const newMessages = currentMessages.map(m => {
|
||||||
if (m.localId === editingMessage.message.localId) {
|
if (m.localId === editingMessage.message.localId) {
|
||||||
return { ...m, parsedContent: finalContent, content: finalContent, rawContent: finalContent }
|
return { ...m, parsedContent: finalContent, content: finalContent, rawContent: finalContent }
|
||||||
@@ -1863,7 +1913,7 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
cancelDeleteRef.current = false
|
cancelDeleteRef.current = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const currentMessages = useChatStore.getState().messages
|
const currentMessages = useChatStore.getState().messages || []
|
||||||
const selectedIds = Array.from(selectedMessages)
|
const selectedIds = Array.from(selectedMessages)
|
||||||
const deletedIds = new Set<number>()
|
const deletedIds = new Set<number>()
|
||||||
|
|
||||||
@@ -1887,7 +1937,7 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
setDeleteProgress({ current: i + 1, total: selectedIds.length })
|
setDeleteProgress({ current: i + 1, total: selectedIds.length })
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalMessages = useChatStore.getState().messages.filter(m => !deletedIds.has(m.localId))
|
const finalMessages = (useChatStore.getState().messages || []).filter(m => !deletedIds.has(m.localId))
|
||||||
useChatStore.getState().setMessages(finalMessages)
|
useChatStore.getState().setMessages(finalMessages)
|
||||||
|
|
||||||
setIsSelectionMode(false)
|
setIsSelectionMode(false)
|
||||||
@@ -1984,26 +2034,41 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
ref={sidebarRef}
|
ref={sidebarRef}
|
||||||
style={{ width: sidebarWidth, minWidth: sidebarWidth, maxWidth: sidebarWidth }}
|
style={{ width: sidebarWidth, minWidth: sidebarWidth, maxWidth: sidebarWidth }}
|
||||||
>
|
>
|
||||||
<div className="session-header">
|
<div className={`session-header session-header-viewport ${foldedView ? 'folded' : ''}`}>
|
||||||
<div className="search-row">
|
{/* 普通 header */}
|
||||||
<div className="search-box expanded">
|
<div className="session-header-panel main-header">
|
||||||
<Search size={14} />
|
<div className="search-row">
|
||||||
<input
|
<div className="search-box expanded">
|
||||||
ref={searchInputRef}
|
<Search size={14} />
|
||||||
type="text"
|
<input
|
||||||
placeholder="搜索"
|
ref={searchInputRef}
|
||||||
value={searchKeyword}
|
type="text"
|
||||||
onChange={(e) => handleSearch(e.target.value)}
|
placeholder="搜索"
|
||||||
/>
|
value={searchKeyword}
|
||||||
{searchKeyword && (
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
<button className="close-search" onClick={handleCloseSearch}>
|
/>
|
||||||
<X size={12} />
|
{searchKeyword && (
|
||||||
</button>
|
<button className="close-search" onClick={handleCloseSearch}>
|
||||||
)}
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button className="icon-btn refresh-btn" onClick={handleRefresh} disabled={isLoadingSessions || isRefreshingSessions}>
|
||||||
|
<RefreshCw size={16} className={(isLoadingSessions || isRefreshingSessions) ? 'spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 折叠群 header */}
|
||||||
|
<div className="session-header-panel folded-header">
|
||||||
|
<div className="folded-view-header">
|
||||||
|
<button className="icon-btn back-btn" onClick={() => setFoldedView(false)}>
|
||||||
|
<ChevronLeft size={18} />
|
||||||
|
</button>
|
||||||
|
<span className="folded-view-title">
|
||||||
|
<Users size={14} />
|
||||||
|
折叠的群聊
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button className="icon-btn refresh-btn" onClick={handleRefresh} disabled={isLoadingSessions || isRefreshingSessions}>
|
|
||||||
<RefreshCw size={16} className={(isLoadingSessions || isRefreshingSessions) ? 'spin' : ''} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2018,7 +2083,6 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
{/* ... (previous content) ... */}
|
{/* ... (previous content) ... */}
|
||||||
{isLoadingSessions ? (
|
{isLoadingSessions ? (
|
||||||
<div className="loading-sessions">
|
<div className="loading-sessions">
|
||||||
{/* ... (skeleton items) ... */}
|
|
||||||
{[1, 2, 3, 4, 5].map(i => (
|
{[1, 2, 3, 4, 5].map(i => (
|
||||||
<div key={i} className="skeleton-item">
|
<div key={i} className="skeleton-item">
|
||||||
<div className="skeleton-avatar" />
|
<div className="skeleton-avatar" />
|
||||||
@@ -2029,36 +2093,65 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : Array.isArray(filteredSessions) && filteredSessions.length > 0 ? (
|
|
||||||
<div
|
|
||||||
className="session-list"
|
|
||||||
ref={sessionListRef}
|
|
||||||
onScroll={() => {
|
|
||||||
isScrollingRef.current = true
|
|
||||||
if (sessionScrollTimeoutRef.current) {
|
|
||||||
clearTimeout(sessionScrollTimeoutRef.current)
|
|
||||||
}
|
|
||||||
sessionScrollTimeoutRef.current = window.setTimeout(() => {
|
|
||||||
isScrollingRef.current = false
|
|
||||||
sessionScrollTimeoutRef.current = null
|
|
||||||
}, 200)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{filteredSessions.map(session => (
|
|
||||||
<SessionItem
|
|
||||||
key={session.username}
|
|
||||||
session={session}
|
|
||||||
isActive={currentSessionId === session.username}
|
|
||||||
onSelect={handleSelectSession}
|
|
||||||
formatTime={formatSessionTime}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="empty-sessions">
|
<div className={`session-list-viewport ${foldedView ? 'folded' : ''}`}>
|
||||||
<MessageSquare />
|
{/* 普通会话列表 */}
|
||||||
<p>暂无会话</p>
|
<div className="session-list-panel main-panel">
|
||||||
<p className="hint">请先在数据管理页面解密数据库</p>
|
{Array.isArray(filteredSessions) && filteredSessions.length > 0 ? (
|
||||||
|
<div
|
||||||
|
className="session-list"
|
||||||
|
ref={sessionListRef}
|
||||||
|
onScroll={() => {
|
||||||
|
isScrollingRef.current = true
|
||||||
|
if (sessionScrollTimeoutRef.current) {
|
||||||
|
clearTimeout(sessionScrollTimeoutRef.current)
|
||||||
|
}
|
||||||
|
sessionScrollTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
isScrollingRef.current = false
|
||||||
|
sessionScrollTimeoutRef.current = null
|
||||||
|
}, 200)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredSessions.map(session => (
|
||||||
|
<SessionItem
|
||||||
|
key={session.username}
|
||||||
|
session={session}
|
||||||
|
isActive={currentSessionId === session.username}
|
||||||
|
onSelect={handleSelectSession}
|
||||||
|
formatTime={formatSessionTime}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="empty-sessions">
|
||||||
|
<MessageSquare />
|
||||||
|
<p>暂无会话</p>
|
||||||
|
<p className="hint">检查你的数据库配置</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 折叠群列表 */}
|
||||||
|
<div className="session-list-panel folded-panel">
|
||||||
|
{foldedSessions.length > 0 ? (
|
||||||
|
<div className="session-list">
|
||||||
|
{foldedSessions.map(session => (
|
||||||
|
<SessionItem
|
||||||
|
key={session.username}
|
||||||
|
session={session}
|
||||||
|
isActive={currentSessionId === session.username}
|
||||||
|
onSelect={handleSelectSession}
|
||||||
|
formatTime={formatSessionTime}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="empty-sessions">
|
||||||
|
<Users size={32} />
|
||||||
|
<p>没有折叠的群聊</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2236,7 +2329,7 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{messages.map((msg, index) => {
|
{(messages || []).map((msg, index) => {
|
||||||
const prevMsg = index > 0 ? messages[index - 1] : undefined
|
const prevMsg = index > 0 ? messages[index - 1] : undefined
|
||||||
const showDateDivider = shouldShowDateDivider(msg, prevMsg)
|
const showDateDivider = shouldShowDateDivider(msg, prevMsg)
|
||||||
|
|
||||||
@@ -2547,6 +2640,39 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
<span className="label">已选:</span>
|
<span className="label">已选:</span>
|
||||||
<span className="value">{batchImageSelectedDates.size} 天,共 {batchImageSelectedCount} 张图片</span>
|
<span className="value">{batchImageSelectedDates.size} 天,共 {batchImageSelectedCount} 张图片</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="info-item">
|
||||||
|
<span className="label">并发数:</span>
|
||||||
|
<div className="batch-concurrency-field">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`batch-concurrency-trigger ${showConcurrencyDropdown ? 'open' : ''}`}
|
||||||
|
onClick={() => setShowConcurrencyDropdown(!showConcurrencyDropdown)}
|
||||||
|
>
|
||||||
|
<span>{batchDecryptConcurrency === 1 ? '1(最慢,最稳)' : batchDecryptConcurrency === 6 ? '6(推荐)' : batchDecryptConcurrency === 20 ? '20(最快,可能卡顿)' : String(batchDecryptConcurrency)}</span>
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
</button>
|
||||||
|
{showConcurrencyDropdown && (
|
||||||
|
<div className="batch-concurrency-dropdown">
|
||||||
|
{[
|
||||||
|
{ value: 1, label: '1(最慢,最稳)' },
|
||||||
|
{ value: 3, label: '3' },
|
||||||
|
{ value: 6, label: '6(推荐)' },
|
||||||
|
{ value: 10, label: '10' },
|
||||||
|
{ value: 20, label: '20(最快,可能卡顿)' },
|
||||||
|
].map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
className={`batch-concurrency-option ${batchDecryptConcurrency === opt.value ? 'active' : ''}`}
|
||||||
|
onClick={() => { setBatchDecryptConcurrency(opt.value); setShowConcurrencyDropdown(false) }}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="batch-warning">
|
<div className="batch-warning">
|
||||||
<AlertCircle size={16} />
|
<AlertCircle size={16} />
|
||||||
@@ -3540,12 +3666,13 @@ function MessageBubble({
|
|||||||
const requestVideoInfo = useCallback(async () => {
|
const requestVideoInfo = useCallback(async () => {
|
||||||
if (!videoMd5 || videoLoadingRef.current) return
|
if (!videoMd5 || videoLoadingRef.current) return
|
||||||
|
|
||||||
videoLoadingRef.current = true
|
videoLoadingRef.current = true
|
||||||
setVideoLoading(true)
|
setVideoLoading(true)
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.video.getVideoInfo(videoMd5)
|
const result = await window.electronAPI.video.getVideoInfo(videoMd5)
|
||||||
if (result && result.success && result.exists) {
|
if (result && result.success && result.exists) {
|
||||||
setVideoInfo({ exists: result.exists,
|
setVideoInfo({
|
||||||
|
exists: result.exists,
|
||||||
videoUrl: result.videoUrl,
|
videoUrl: result.videoUrl,
|
||||||
coverUrl: result.coverUrl,
|
coverUrl: result.coverUrl,
|
||||||
thumbUrl: result.thumbUrl
|
thumbUrl: result.thumbUrl
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { useEffect, useState, useRef } from 'react'
|
import { useEffect, useState, useRef } from 'react'
|
||||||
import { NotificationToast, type NotificationData } from '../components/NotificationToast'
|
import { NotificationToast, type NotificationData } from '../components/NotificationToast'
|
||||||
import { useThemeStore } from '../stores/themeStore'
|
|
||||||
import '../components/NotificationToast.scss'
|
import '../components/NotificationToast.scss'
|
||||||
import './NotificationWindow.scss'
|
import './NotificationWindow.scss'
|
||||||
|
|
||||||
export default function NotificationWindow() {
|
export default function NotificationWindow() {
|
||||||
const { currentTheme, themeMode } = useThemeStore()
|
|
||||||
const [notification, setNotification] = useState<NotificationData | null>(null)
|
const [notification, setNotification] = useState<NotificationData | null>(null)
|
||||||
const [prevNotification, setPrevNotification] = useState<NotificationData | null>(null)
|
const [prevNotification, setPrevNotification] = useState<NotificationData | null>(null)
|
||||||
|
|
||||||
@@ -19,12 +17,6 @@ export default function NotificationWindow() {
|
|||||||
|
|
||||||
const notificationRef = useRef<NotificationData | null>(null)
|
const notificationRef = useRef<NotificationData | null>(null)
|
||||||
|
|
||||||
// 应用主题到通知窗口
|
|
||||||
useEffect(() => {
|
|
||||||
document.documentElement.setAttribute('data-theme', currentTheme)
|
|
||||||
document.documentElement.setAttribute('data-mode', themeMode)
|
|
||||||
}, [currentTheme, themeMode])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
notificationRef.current = notification
|
notificationRef.current = notification
|
||||||
}, [notification])
|
}, [notification])
|
||||||
|
|||||||
@@ -2173,3 +2173,70 @@
|
|||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brute-force-progress {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
animation: slideUp 0.3s ease;
|
||||||
|
|
||||||
|
.status-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0;
|
||||||
|
// 增加文字呼吸灯效果,表明正在运行
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.percent {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
.fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--primary) 0%, color-mix(in srgb, var(--primary) 60%, white) 100%);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
// 流光扫过的高亮特效
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(255, 255, 255, 0.3),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
animation: progress-shimmer 1.5s infinite linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,6 +82,8 @@ function SettingsPage() {
|
|||||||
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
|
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
const exportConcurrencyDropdownRef = useRef<HTMLDivElement>(null)
|
const exportConcurrencyDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
const [cachePath, setCachePath] = useState('')
|
const [cachePath, setCachePath] = useState('')
|
||||||
|
const [imageKeyProgress, setImageKeyProgress] = useState(0)
|
||||||
|
const [imageKeyPercent, setImageKeyPercent] = useState<number | null>(null)
|
||||||
|
|
||||||
const [logEnabled, setLogEnabled] = useState(false)
|
const [logEnabled, setLogEnabled] = useState(false)
|
||||||
const [whisperModelName, setWhisperModelName] = useState('base')
|
const [whisperModelName, setWhisperModelName] = useState('base')
|
||||||
@@ -222,8 +224,28 @@ function SettingsPage() {
|
|||||||
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
|
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
|
||||||
setDbKeyStatus(payload.message)
|
setDbKeyStatus(payload.message)
|
||||||
})
|
})
|
||||||
const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string }) => {
|
|
||||||
setImageKeyStatus(payload.message)
|
const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string, percent?: number }) => {
|
||||||
|
let msg = payload.message;
|
||||||
|
let pct = payload.percent;
|
||||||
|
|
||||||
|
// 如果后端没有显式传 percent,则用正则从字符串中提取如 "(12.5%)"
|
||||||
|
if (pct === undefined) {
|
||||||
|
const match = msg.match(/\(([\d.]+)%\)/);
|
||||||
|
if (match) {
|
||||||
|
pct = parseFloat(match[1]);
|
||||||
|
// 将百分比从文本中剥离,让 UI 更清爽
|
||||||
|
msg = msg.replace(/\s*\([\d.]+%\)/, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setImageKeyStatus(msg);
|
||||||
|
if (pct !== undefined) {
|
||||||
|
setImageKeyPercent(pct);
|
||||||
|
} else if (msg.includes('启动多核') || msg.includes('定位') || msg.includes('准备')) {
|
||||||
|
// 预热阶段
|
||||||
|
setImageKeyPercent(0);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
removeDb?.()
|
removeDb?.()
|
||||||
@@ -745,15 +767,18 @@ function SettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleAutoGetImageKey = async () => {
|
const handleAutoGetImageKey = async () => {
|
||||||
if (isFetchingImageKey) return
|
if (isFetchingImageKey) return;
|
||||||
if (!dbPath) {
|
if (!dbPath) {
|
||||||
showMessage('请先选择数据库目录', false)
|
showMessage('请先选择数据库目录', false);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
setIsFetchingImageKey(true)
|
setIsFetchingImageKey(true);
|
||||||
setImageKeyStatus('正在准备获取图片密钥...')
|
setImageKeyPercent(0)
|
||||||
|
setImageKeyStatus('正在初始化...');
|
||||||
|
setImageKeyProgress(0); // 重置进度
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath
|
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath;
|
||||||
const result = await window.electronAPI.key.autoGetImageKey(accountPath)
|
const result = await window.electronAPI.key.autoGetImageKey(accountPath)
|
||||||
if (result.success && result.aesKey) {
|
if (result.success && result.aesKey) {
|
||||||
if (typeof result.xorKey === 'number') {
|
if (typeof result.xorKey === 'number') {
|
||||||
@@ -1351,8 +1376,21 @@ function SettingsPage() {
|
|||||||
<button className="btn btn-secondary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
|
<button className="btn btn-secondary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
|
||||||
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
|
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
|
||||||
</button>
|
</button>
|
||||||
{imageKeyStatus && <div className="form-hint status-text">{imageKeyStatus}</div>}
|
{isFetchingImageKey ? (
|
||||||
{isFetchingImageKey && <div className="form-hint status-text">正在扫描内存,请稍候...</div>}
|
<div className="brute-force-progress">
|
||||||
|
<div className="status-header">
|
||||||
|
<span className="status-text">{imageKeyStatus || '正在启动...'}</span>
|
||||||
|
{imageKeyPercent !== null && <span className="percent">{imageKeyPercent.toFixed(1)}%</span>}
|
||||||
|
</div>
|
||||||
|
{imageKeyPercent !== null && (
|
||||||
|
<div className="progress-bar-container">
|
||||||
|
<div className="fill" style={{ width: `${imageKeyPercent}%` }}></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
imageKeyStatus && <div className="form-hint status-text" style={{ marginTop: '8px' }}>{imageKeyStatus}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -2075,8 +2113,8 @@ function SettingsPage() {
|
|||||||
<label>应用锁状态</label>
|
<label>应用锁状态</label>
|
||||||
<span className="form-hint">{
|
<span className="form-hint">{
|
||||||
isLockMode ? '已开启' :
|
isLockMode ? '已开启' :
|
||||||
authEnabled ? '旧版模式 — 请重新设置密码以升级为新模式提高安全性' :
|
authEnabled ? '旧版模式 — 请重新设置密码以升级为新模式提高安全性' :
|
||||||
'未开启 — 请设置密码以开启'
|
'未开启 — 请设置密码以开启'
|
||||||
}</span>
|
}</span>
|
||||||
</div>
|
</div>
|
||||||
{authEnabled && !showDisableLockInput && (
|
{authEnabled && !showDisableLockInput && (
|
||||||
|
|||||||
@@ -190,6 +190,32 @@
|
|||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
border-color: var(--text-secondary);
|
border-color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.delete-btn:hover {
|
||||||
|
color: #ff4d4f;
|
||||||
|
border-color: rgba(255, 77, 79, 0.4);
|
||||||
|
background: rgba(255, 77, 79, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-protected-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
color: var(--color-success, #4caf50);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 3px 7px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: rgba(76, 175, 80, 0.08);
|
||||||
|
border: 1px solid rgba(76, 175, 80, 0.2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +223,258 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sns-post-item:hover .post-protected-badge {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除确认弹窗
|
||||||
|
.sns-confirm-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.15);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sns-confirm-dialog {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 28px 28px 22px;
|
||||||
|
width: 300px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
||||||
|
|
||||||
|
.sns-confirm-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 77, 79, 0.1);
|
||||||
|
color: #ff4d4f;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sns-confirm-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sns-confirm-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sns-confirm-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 4px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
flex: 1;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sns-confirm-cancel {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sns-confirm-ok {
|
||||||
|
background: #ff4d4f;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #ff4d4f;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #ff7875;
|
||||||
|
border-color: #ff7875;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 朋友圈防删除插件对话框
|
||||||
|
.sns-protect-dialog {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 340px;
|
||||||
|
padding: 32px 28px 24px;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||||
|
|
||||||
|
.sns-protect-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
right: 14px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sns-protect-hero {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sns-protect-icon-wrap {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: rgba(76, 175, 80, 0.12);
|
||||||
|
color: var(--color-success, #4caf50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sns-protect-title {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sns-protect-status-badge {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
|
||||||
|
&.on {
|
||||||
|
background: rgba(76, 175, 80, 0.12);
|
||||||
|
color: var(--color-success, #4caf50);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.off {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sns-protect-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sns-protect-feedback {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&.success {
|
||||||
|
background: rgba(76, 175, 80, 0.1);
|
||||||
|
color: var(--color-success, #4caf50);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
background: rgba(244, 67, 54, 0.1);
|
||||||
|
color: var(--color-error, #f44336);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sns-protect-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sns-protect-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&.primary {
|
||||||
|
background: var(--color-primary, #1677ff);
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.danger {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 77, 79, 0.08);
|
||||||
|
color: #ff4d4f;
|
||||||
|
border-color: rgba(255, 77, 79, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.post-text {
|
.post-text {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
@@ -322,6 +600,13 @@
|
|||||||
.comment-colon {
|
.comment-colon {
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comment-custom-emoji {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -950,7 +1235,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(0, 0, 0, 0.05);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -992,7 +1277,7 @@
|
|||||||
Export Dialog
|
Export Dialog
|
||||||
========================================= */
|
========================================= */
|
||||||
.export-dialog {
|
.export-dialog {
|
||||||
background: rgba(255, 255, 255, 0.88);
|
background: var(--bg-secondary);
|
||||||
border-radius: var(--sns-border-radius-lg);
|
border-radius: var(--sns-border-radius-lg);
|
||||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
|
||||||
width: 480px;
|
width: 480px;
|
||||||
@@ -1028,7 +1313,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(0, 0, 0, 0.05);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useLayoutEffect, useState, useRef, useCallback } from 'react'
|
import { useEffect, useLayoutEffect, useState, useRef, useCallback } from 'react'
|
||||||
import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Users, Info, ChevronLeft, ChevronRight } from 'lucide-react'
|
import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Users, Info, ChevronLeft, ChevronRight, Shield, ShieldOff } from 'lucide-react'
|
||||||
import JumpToDateDialog from '../components/JumpToDateDialog'
|
import JumpToDateDialog from '../components/JumpToDateDialog'
|
||||||
import './SnsPage.scss'
|
import './SnsPage.scss'
|
||||||
import { SnsPost } from '../types/sns'
|
import { SnsPost } from '../types/sns'
|
||||||
@@ -46,6 +46,12 @@ export default function SnsPage() {
|
|||||||
const [calendarPicker, setCalendarPicker] = useState<{ field: 'start' | 'end'; month: Date } | null>(null)
|
const [calendarPicker, setCalendarPicker] = useState<{ field: 'start' | 'end'; month: Date } | null>(null)
|
||||||
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
|
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
|
||||||
|
|
||||||
|
// 触发器相关状态
|
||||||
|
const [showTriggerDialog, setShowTriggerDialog] = useState(false)
|
||||||
|
const [triggerInstalled, setTriggerInstalled] = useState<boolean | null>(null)
|
||||||
|
const [triggerLoading, setTriggerLoading] = useState(false)
|
||||||
|
const [triggerMessage, setTriggerMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||||
|
|
||||||
const postsContainerRef = useRef<HTMLDivElement>(null)
|
const postsContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const [hasNewer, setHasNewer] = useState(false)
|
const [hasNewer, setHasNewer] = useState(false)
|
||||||
const [loadingNewer, setLoadingNewer] = useState(false)
|
const [loadingNewer, setLoadingNewer] = useState(false)
|
||||||
@@ -56,7 +62,6 @@ export default function SnsPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
postsRef.current = posts
|
postsRef.current = posts
|
||||||
}, [posts])
|
}, [posts])
|
||||||
|
|
||||||
// 在 DOM 更新后、浏览器绘制前同步调整滚动位置,防止向上加载时页面跳动
|
// 在 DOM 更新后、浏览器绘制前同步调整滚动位置,防止向上加载时页面跳动
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const snapshot = scrollAdjustmentRef.current;
|
const snapshot = scrollAdjustmentRef.current;
|
||||||
@@ -285,6 +290,25 @@ export default function SnsPage() {
|
|||||||
<div className="feed-header">
|
<div className="feed-header">
|
||||||
<h2>朋友圈</h2>
|
<h2>朋友圈</h2>
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setTriggerMessage(null)
|
||||||
|
setShowTriggerDialog(true)
|
||||||
|
setTriggerLoading(true)
|
||||||
|
try {
|
||||||
|
const r = await window.electronAPI.sns.checkBlockDeleteTrigger()
|
||||||
|
setTriggerInstalled(r.success ? (r.installed ?? false) : false)
|
||||||
|
} catch {
|
||||||
|
setTriggerInstalled(false)
|
||||||
|
} finally {
|
||||||
|
setTriggerLoading(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="icon-btn"
|
||||||
|
title="朋友圈保护插件"
|
||||||
|
>
|
||||||
|
<Shield size={20} />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setExportResult(null)
|
setExportResult(null)
|
||||||
@@ -329,7 +353,7 @@ export default function SnsPage() {
|
|||||||
{posts.map(post => (
|
{posts.map(post => (
|
||||||
<SnsPostItem
|
<SnsPostItem
|
||||||
key={post.id}
|
key={post.id}
|
||||||
post={post}
|
post={{ ...post, isProtected: triggerInstalled === true }}
|
||||||
onPreview={(src, isVideo, liveVideoPath) => {
|
onPreview={(src, isVideo, liveVideoPath) => {
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
void window.electronAPI.window.openVideoPlayerWindow(src)
|
void window.electronAPI.window.openVideoPlayerWindow(src)
|
||||||
@@ -338,6 +362,7 @@ export default function SnsPage() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDebug={(p) => setDebugPost(p)}
|
onDebug={(p) => setDebugPost(p)}
|
||||||
|
onDelete={(postId) => setPosts(prev => prev.filter(p => p.id !== postId))}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -426,6 +451,101 @@ export default function SnsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 朋友圈防删除插件对话框 */}
|
||||||
|
{showTriggerDialog && (
|
||||||
|
<div className="modal-overlay" onClick={() => { setShowTriggerDialog(false); setTriggerMessage(null) }}>
|
||||||
|
<div className="sns-protect-dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button className="close-btn sns-protect-close" onClick={() => { setShowTriggerDialog(false); setTriggerMessage(null) }}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 顶部图标区 */}
|
||||||
|
<div className="sns-protect-hero">
|
||||||
|
<div className={`sns-protect-icon-wrap ${triggerInstalled ? 'active' : ''}`}>
|
||||||
|
{triggerLoading
|
||||||
|
? <RefreshCw size={28} className="spinning" />
|
||||||
|
: triggerInstalled
|
||||||
|
? <Shield size={28} />
|
||||||
|
: <ShieldOff size={28} />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className="sns-protect-title">朋友圈防删除</div>
|
||||||
|
<div className={`sns-protect-status-badge ${triggerInstalled ? 'on' : 'off'}`}>
|
||||||
|
{triggerLoading ? '检查中…' : triggerInstalled ? '已启用' : '未启用'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 说明 */}
|
||||||
|
<div className="sns-protect-desc">
|
||||||
|
启用后,WeFlow将拦截朋友圈删除操作<br/>已同步的动态不会从本地数据库中消失<br/>新的动态仍可正常同步。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作反馈 */}
|
||||||
|
{triggerMessage && (
|
||||||
|
<div className={`sns-protect-feedback ${triggerMessage.type}`}>
|
||||||
|
{triggerMessage.type === 'success' ? <CheckCircle size={14} /> : <AlertCircle size={14} />}
|
||||||
|
<span>{triggerMessage.text}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className="sns-protect-actions">
|
||||||
|
{!triggerInstalled ? (
|
||||||
|
<button
|
||||||
|
className="sns-protect-btn primary"
|
||||||
|
disabled={triggerLoading}
|
||||||
|
onClick={async () => {
|
||||||
|
setTriggerLoading(true)
|
||||||
|
setTriggerMessage(null)
|
||||||
|
try {
|
||||||
|
const r = await window.electronAPI.sns.installBlockDeleteTrigger()
|
||||||
|
if (r.success) {
|
||||||
|
setTriggerInstalled(true)
|
||||||
|
setTriggerMessage({ type: 'success', text: r.alreadyInstalled ? '插件已存在,无需重复安装' : '已启用朋友圈防删除保护' })
|
||||||
|
} else {
|
||||||
|
setTriggerMessage({ type: 'error', text: r.error || '安装失败' })
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setTriggerMessage({ type: 'error', text: e.message || String(e) })
|
||||||
|
} finally {
|
||||||
|
setTriggerLoading(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Shield size={15} />
|
||||||
|
启用保护
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="sns-protect-btn danger"
|
||||||
|
disabled={triggerLoading}
|
||||||
|
onClick={async () => {
|
||||||
|
setTriggerLoading(true)
|
||||||
|
setTriggerMessage(null)
|
||||||
|
try {
|
||||||
|
const r = await window.electronAPI.sns.uninstallBlockDeleteTrigger()
|
||||||
|
if (r.success) {
|
||||||
|
setTriggerInstalled(false)
|
||||||
|
setTriggerMessage({ type: 'success', text: '已关闭朋友圈防删除保护' })
|
||||||
|
} else {
|
||||||
|
setTriggerMessage({ type: 'error', text: r.error || '卸载失败' })
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setTriggerMessage({ type: 'error', text: e.message || String(e) })
|
||||||
|
} finally {
|
||||||
|
setTriggerLoading(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ShieldOff size={15} />
|
||||||
|
关闭保护
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 导出对话框 */}
|
{/* 导出对话框 */}
|
||||||
{showExportDialog && (
|
{showExportDialog && (
|
||||||
<div className="modal-overlay" onClick={() => !isExporting && setShowExportDialog(false)}>
|
<div className="modal-overlay" onClick={() => !isExporting && setShowExportDialog(false)}>
|
||||||
|
|||||||
@@ -803,3 +803,79 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.brute-force-progress {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
animation: slideUp 0.3s ease;
|
||||||
|
|
||||||
|
.status-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0;
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.percent {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
.fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--primary) 0%, color-mix(in srgb, var(--primary) 60%, white) 100%);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(255, 255, 255, 0.3),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
animation: progress-shimmer 1.5s infinite linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes progress-shimmer {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(100%); }
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
const [dbKeyStatus, setDbKeyStatus] = useState('')
|
const [dbKeyStatus, setDbKeyStatus] = useState('')
|
||||||
const [imageKeyStatus, setImageKeyStatus] = useState('')
|
const [imageKeyStatus, setImageKeyStatus] = useState('')
|
||||||
const [isManualStartPrompt, setIsManualStartPrompt] = useState(false)
|
const [isManualStartPrompt, setIsManualStartPrompt] = useState(false)
|
||||||
|
const [imageKeyPercent, setImageKeyPercent] = useState<number | null>(null)
|
||||||
|
|
||||||
// 安全相关 state
|
// 安全相关 state
|
||||||
const [enableAuth, setEnableAuth] = useState(false)
|
const [enableAuth, setEnableAuth] = useState(false)
|
||||||
@@ -111,8 +112,25 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
|
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
|
||||||
setDbKeyStatus(payload.message)
|
setDbKeyStatus(payload.message)
|
||||||
})
|
})
|
||||||
const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string }) => {
|
const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string, percent?: number }) => {
|
||||||
setImageKeyStatus(payload.message)
|
let msg = payload.message;
|
||||||
|
let pct = payload.percent;
|
||||||
|
|
||||||
|
// 解析文本中的百分比
|
||||||
|
if (pct === undefined) {
|
||||||
|
const match = msg.match(/\(([\d.]+)%\)/);
|
||||||
|
if (match) {
|
||||||
|
pct = parseFloat(match[1]);
|
||||||
|
msg = msg.replace(/\s*\([\d.]+%\)/, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setImageKeyStatus(msg);
|
||||||
|
if (pct !== undefined) {
|
||||||
|
setImageKeyPercent(pct);
|
||||||
|
} else if (msg.includes('启动多核') || msg.includes('定位') || msg.includes('准备')) {
|
||||||
|
setImageKeyPercent(0);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
removeDb?.()
|
removeDb?.()
|
||||||
@@ -297,6 +315,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
}
|
}
|
||||||
setIsFetchingImageKey(true)
|
setIsFetchingImageKey(true)
|
||||||
setError('')
|
setError('')
|
||||||
|
setImageKeyPercent(0)
|
||||||
setImageKeyStatus('正在准备获取图片密钥...')
|
setImageKeyStatus('正在准备获取图片密钥...')
|
||||||
try {
|
try {
|
||||||
// 拼接完整的账号目录,确保 KeyService 能准确找到模板文件
|
// 拼接完整的账号目录,确保 KeyService 能准确找到模板文件
|
||||||
@@ -752,10 +771,25 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button className="btn btn-secondary btn-block mt-4" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
|
<button className="btn btn-secondary btn-block mt-4" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
|
||||||
{isFetchingImageKey ? '扫描中...' : '自动获取图片密钥'}
|
{isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{imageKeyStatus && <div className="status-message">{imageKeyStatus}</div>}
|
{isFetchingImageKey ? (
|
||||||
|
<div className="brute-force-progress">
|
||||||
|
<div className="status-header">
|
||||||
|
<span className="status-text">{imageKeyStatus || '正在启动...'}</span>
|
||||||
|
{imageKeyPercent !== null && <span className="percent">{imageKeyPercent.toFixed(1)}%</span>}
|
||||||
|
</div>
|
||||||
|
{imageKeyPercent !== null && (
|
||||||
|
<div className="progress-bar-container">
|
||||||
|
<div className="fill" style={{ width: `${imageKeyPercent}%` }}></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
imageKeyStatus && <div className="status-message" style={{ marginTop: '12px' }}>{imageKeyStatus}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="field-hint">请在微信中打开几张图片后再点击获取</div>
|
<div className="field-hint">请在微信中打开几张图片后再点击获取</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -86,15 +86,16 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
if (m.localId && m.localId > 0) return `l:${m.localId}`
|
if (m.localId && m.localId > 0) return `l:${m.localId}`
|
||||||
return `t:${m.createTime}:${m.sortSeq || 0}:${m.serverId || 0}`
|
return `t:${m.createTime}:${m.sortSeq || 0}:${m.serverId || 0}`
|
||||||
}
|
}
|
||||||
const existingKeys = new Set(state.messages.map(getMsgKey))
|
const currentMessages = state.messages || []
|
||||||
|
const existingKeys = new Set(currentMessages.map(getMsgKey))
|
||||||
const filtered = newMessages.filter(m => !existingKeys.has(getMsgKey(m)))
|
const filtered = newMessages.filter(m => !existingKeys.has(getMsgKey(m)))
|
||||||
|
|
||||||
if (filtered.length === 0) return state
|
if (filtered.length === 0) return state
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages: prepend
|
messages: prepend
|
||||||
? [...filtered, ...state.messages]
|
? [...filtered, ...currentMessages]
|
||||||
: [...state.messages, ...filtered]
|
: [...currentMessages, ...filtered]
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
7
src/types/electron.d.ts
vendored
7
src/types/electron.d.ts
vendored
@@ -500,7 +500,7 @@ export interface ElectronAPI {
|
|||||||
}
|
}
|
||||||
}>
|
}>
|
||||||
likes: Array<string>
|
likes: Array<string>
|
||||||
comments: Array<{ id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }>
|
comments: Array<{ id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: Array<{ url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }> }>
|
||||||
rawXml?: string
|
rawXml?: string
|
||||||
}>
|
}>
|
||||||
error?: string
|
error?: string
|
||||||
@@ -520,6 +520,11 @@ export interface ElectronAPI {
|
|||||||
onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void
|
onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void
|
||||||
selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }>
|
selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }>
|
||||||
getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }>
|
getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }>
|
||||||
|
installBlockDeleteTrigger: () => Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }>
|
||||||
|
uninstallBlockDeleteTrigger: () => Promise<{ success: boolean; error?: string }>
|
||||||
|
checkBlockDeleteTrigger: () => Promise<{ success: boolean; installed?: boolean; error?: string }>
|
||||||
|
deleteSnsPost: (postId: string) => Promise<{ success: boolean; error?: string }>
|
||||||
|
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => Promise<{ success: boolean; localPath?: string; error?: string }>
|
||||||
}
|
}
|
||||||
http: {
|
http: {
|
||||||
start: (port?: number) => Promise<{ success: boolean; port?: number; error?: string }>
|
start: (port?: number) => Promise<{ success: boolean; port?: number; error?: string }>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export interface ChatSession {
|
|||||||
lastMsgSender?: string
|
lastMsgSender?: string
|
||||||
lastSenderDisplayName?: string
|
lastSenderDisplayName?: string
|
||||||
selfWxid?: string // Helper field to avoid extra API calls
|
selfWxid?: string // Helper field to avoid extra API calls
|
||||||
|
isFolded?: boolean // 是否已折叠进"折叠的群聊"
|
||||||
|
isMuted?: boolean // 是否开启免打扰
|
||||||
}
|
}
|
||||||
|
|
||||||
// 联系人
|
// 联系人
|
||||||
@@ -51,6 +53,7 @@ export interface Message {
|
|||||||
imageDatName?: string
|
imageDatName?: string
|
||||||
emojiCdnUrl?: string
|
emojiCdnUrl?: string
|
||||||
emojiMd5?: string
|
emojiMd5?: string
|
||||||
|
emojiLocalPath?: string // 本地缓存路径(转发表情包无 CDN URL 时使用)
|
||||||
voiceDurationSeconds?: number
|
voiceDurationSeconds?: number
|
||||||
videoMd5?: string
|
videoMd5?: string
|
||||||
// 引用消息
|
// 引用消息
|
||||||
|
|||||||
@@ -16,16 +16,27 @@ export interface SnsMedia {
|
|||||||
livePhoto?: SnsLivePhoto
|
livePhoto?: SnsLivePhoto
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SnsCommentEmoji {
|
||||||
|
url: string
|
||||||
|
md5: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
encryptUrl?: string
|
||||||
|
aesKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface SnsComment {
|
export interface SnsComment {
|
||||||
id: string
|
id: string
|
||||||
nickname: string
|
nickname: string
|
||||||
content: string
|
content: string
|
||||||
refCommentId: string
|
refCommentId: string
|
||||||
refNickname?: string
|
refNickname?: string
|
||||||
|
emojis?: SnsCommentEmoji[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SnsPost {
|
export interface SnsPost {
|
||||||
id: string
|
id: string
|
||||||
|
tid?: string // 数据库主键(雪花 ID),用于精确删除
|
||||||
username: string
|
username: string
|
||||||
nickname: string
|
nickname: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
@@ -38,6 +49,7 @@ export interface SnsPost {
|
|||||||
rawXml?: string
|
rawXml?: string
|
||||||
linkTitle?: string
|
linkTitle?: string
|
||||||
linkUrl?: string
|
linkUrl?: string
|
||||||
|
isProtected?: boolean // 是否受保护(已安装时标记)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SnsLinkCardData {
|
export interface SnsLinkCardData {
|
||||||
|
|||||||
Reference in New Issue
Block a user