mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
feat: 完善多账号连接诊断与聊天媒体导出
- 新增微信账号发现、环境诊断和分步数据库连接引导 - 支持按账号安全保存数据库密钥及快速切换账号 - 完善 WCDB 历史消息分片读取和分页状态提示 - 支持导出图片、视频和语音,提供原图优先及缩略图回退 - 更新安装指引、兼容版本说明和相关自动化测试
This commit is contained in:
@@ -197,10 +197,18 @@ WechatExplorer 希望不仅仅是一个聊天记录查看工具,更希望成
|
||||
|
||||
### 下载并安装
|
||||
|
||||
从 [GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases) 下载对应系统的安装包:
|
||||
当前 WechatExplorer / 迹忆版本:`v2.1.6`。
|
||||
|
||||
- Windows:下载 `-setup.exe` 并按安装向导完成安装。
|
||||
- macOS:下载 `.dmg`,将 WechatExplorer 拖入“应用程序”。首次打开若被系统拦截,请在“系统设置 → 隐私与安全性”中允许打开。
|
||||
应用安装包:[WechatExplorer GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases)。Windows 选择 `-setup.exe`,macOS 按处理器架构选择对应 `.dmg`。
|
||||
|
||||
| 系统 | 已测试的微信客户端 |
|
||||
| ------- | ------------------------------------------------------------------------------------------------- |
|
||||
| Windows | [微信 Windows `4.1.9.57`](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57) |
|
||||
| macOS | [微信 macOS `4.1.8.100`](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100) |
|
||||
|
||||
微信客户端来自上表对应的第三方版本存档,请自行核对来源与文件完整性。
|
||||
|
||||
正常覆盖安装只会替换应用程序文件,WechatExplorer / 迹忆不会主动删除或修改微信原始聊天记录;但应用缓存和本地设置可能随版本升级变化。升级前仍建议使用微信官方迁移或备份功能备份重要记录,不要将唯一副本保存在单一设备。
|
||||
|
||||
### 连接微信
|
||||
|
||||
|
||||
@@ -13,12 +13,14 @@
|
||||
- [我遇到问题](#遇到问题)
|
||||
- [我想让 Agent 读取微信](#接入-api-reader-skill-或-agent)
|
||||
|
||||
> 正常覆盖安装只会替换应用程序文件,WechatExplorer / 迹忆不会主动删除或修改微信原始聊天记录。应用缓存和本地设置可能随版本升级发生变化。系统故障、磁盘异常、误操作和微信自身迁移不受本应用控制,因此升级前仍建议使用微信官方迁移或备份功能备份重要聊天记录,不要将唯一副本保存在单一设备。
|
||||
|
||||
## 开始前确认
|
||||
|
||||
| 系统 | 已测试的微信版本 | 需要注意 |
|
||||
| --- | --- | --- |
|
||||
| macOS | `4.1.8.100` | 自动获取数据库密钥前需要关闭 SIP 并完成授权 |
|
||||
| Windows | `4.1.9.57` | 已完整支持;首次使用时请确认微信数据目录 |
|
||||
| 系统 | 已测试的微信客户端 | 需要注意 |
|
||||
| ------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------- |
|
||||
| macOS | [微信 macOS `4.1.8.100`](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100) | 自动获取数据库密钥前需要关闭 SIP 并完成授权 |
|
||||
| Windows | [微信 Windows `4.1.9.57`](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57) | 已完整支持;首次使用时请确认微信数据目录 |
|
||||
|
||||
- WechatExplorer 当前面向微信 4.0 数据结构。
|
||||
- Windows 不需要关闭 SIP。
|
||||
@@ -26,7 +28,7 @@
|
||||
- WechatExplorer 必须取得当前微信账号对应的数据库密钥才能读取聊天记录。
|
||||
- 请只处理你有权访问的微信数据。
|
||||
|
||||
下载入口:[GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases)
|
||||
WechatExplorer / 迹忆应用安装包:[GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases)。Windows 选择 `-setup.exe`,macOS 按处理器架构选择对应 `.dmg`。微信客户端请使用上方“已测试的微信客户端”链接。
|
||||
|
||||
## 第一次连接微信
|
||||
|
||||
@@ -141,7 +143,7 @@ AI 功能使用你配置的模型服务。相关聊天内容会按请求发送
|
||||
先判断你遇到的现象,再按对应路径处理。
|
||||
|
||||
| 现象 | 优先检查 |
|
||||
| --- | --- |
|
||||
| --------------------------------- | -------------------------------------------------------- |
|
||||
| 软件打不开 | macOS 安全提示或应用损坏处理;Windows 重新运行安装包 |
|
||||
| 找不到微信数据 | 在首次连接页面或设置中确认数据目录,Windows 检查目录层级 |
|
||||
| 获取不到数据库密钥 | 微信是否停留在登录页面、微信和应用是否同时运行 |
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { app, safeStorage } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import crypto from 'crypto'
|
||||
import type { DatabaseKeyStorageResult } from '../shared/database-key'
|
||||
|
||||
const normalizeDatabaseKey = (value: string): string => value.trim().replace(/^0x/i, '')
|
||||
@@ -9,32 +10,42 @@ export const isValidDatabaseKey = (value: string): boolean =>
|
||||
/^[0-9a-f]{64}$/i.test(normalizeDatabaseKey(value))
|
||||
|
||||
export class DatabaseKeyStore {
|
||||
private get filePath(): string {
|
||||
private get legacyFilePath(): string {
|
||||
return path.join(app.getPath('userData'), 'wechat-db-key.bin')
|
||||
}
|
||||
|
||||
async getStatus(): Promise<{ saved: boolean; encryptionAvailable: boolean }> {
|
||||
private get directoryPath(): string {
|
||||
return path.join(app.getPath('userData'), 'database-keys')
|
||||
}
|
||||
|
||||
private filePath(accountRoot: string): string {
|
||||
const normalized = path.resolve(accountRoot).toLowerCase()
|
||||
const id = crypto.createHash('sha256').update(normalized).digest('hex')
|
||||
return path.join(this.directoryPath, `${id}.bin`)
|
||||
}
|
||||
|
||||
async getStatus(accountRoot: string): Promise<{ saved: boolean; encryptionAvailable: boolean }> {
|
||||
return {
|
||||
saved: await fs.pathExists(this.filePath),
|
||||
saved: Boolean(accountRoot) && (await fs.pathExists(this.filePath(accountRoot))),
|
||||
encryptionAvailable: safeStorage.isEncryptionAvailable()
|
||||
}
|
||||
}
|
||||
|
||||
async load(): Promise<DatabaseKeyStorageResult> {
|
||||
async load(accountRoot: string): Promise<DatabaseKeyStorageResult> {
|
||||
try {
|
||||
const status = await this.getStatus()
|
||||
const status = await this.getStatus(accountRoot)
|
||||
if (!status.saved) return { success: true, ...status }
|
||||
if (!status.encryptionAvailable) {
|
||||
return { success: false, error: '系统安全存储不可用', ...status }
|
||||
}
|
||||
const encrypted = await fs.readFile(this.filePath)
|
||||
const encrypted = await fs.readFile(this.filePath(accountRoot))
|
||||
const key = normalizeDatabaseKey(safeStorage.decryptString(encrypted))
|
||||
if (!isValidDatabaseKey(key)) {
|
||||
return { success: false, error: '已保存的密钥格式无效', ...status }
|
||||
}
|
||||
return { success: true, key, ...status }
|
||||
} catch (error) {
|
||||
const status = await this.getStatus()
|
||||
const status = await this.getStatus(accountRoot)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
@@ -43,13 +54,49 @@ export class DatabaseKeyStore {
|
||||
}
|
||||
}
|
||||
|
||||
async save(rawKey: string): Promise<DatabaseKeyStorageResult> {
|
||||
async loadLegacy(): Promise<DatabaseKeyStorageResult> {
|
||||
const saved = await fs.pathExists(this.legacyFilePath)
|
||||
const encryptionAvailable = safeStorage.isEncryptionAvailable()
|
||||
if (!saved) return { success: true, saved, encryptionAvailable }
|
||||
if (!encryptionAvailable) {
|
||||
return { success: false, error: '系统安全存储不可用', saved, encryptionAvailable }
|
||||
}
|
||||
try {
|
||||
const key = normalizeDatabaseKey(
|
||||
safeStorage.decryptString(await fs.readFile(this.legacyFilePath))
|
||||
)
|
||||
return isValidDatabaseKey(key)
|
||||
? { success: true, key, saved, encryptionAvailable }
|
||||
: { success: false, error: '旧版密钥格式无效', saved, encryptionAvailable }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
saved,
|
||||
encryptionAvailable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async clearLegacy(): Promise<void> {
|
||||
await fs.remove(this.legacyFilePath)
|
||||
}
|
||||
|
||||
async save(accountRoot: string, rawKey: string): Promise<DatabaseKeyStorageResult> {
|
||||
const key = normalizeDatabaseKey(rawKey)
|
||||
if (!accountRoot.trim()) {
|
||||
return {
|
||||
success: false,
|
||||
error: '请先选择微信账号',
|
||||
saved: false,
|
||||
encryptionAvailable: safeStorage.isEncryptionAvailable()
|
||||
}
|
||||
}
|
||||
if (!isValidDatabaseKey(key)) {
|
||||
return {
|
||||
success: false,
|
||||
error: '密钥必须是 64 位十六进制字符',
|
||||
saved: await fs.pathExists(this.filePath),
|
||||
saved: await fs.pathExists(this.filePath(accountRoot)),
|
||||
encryptionAvailable: safeStorage.isEncryptionAvailable()
|
||||
}
|
||||
}
|
||||
@@ -57,29 +104,30 @@ export class DatabaseKeyStore {
|
||||
return {
|
||||
success: false,
|
||||
error: '系统安全存储不可用',
|
||||
saved: await fs.pathExists(this.filePath),
|
||||
saved: await fs.pathExists(this.filePath(accountRoot)),
|
||||
encryptionAvailable: false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.ensureDir(path.dirname(this.filePath))
|
||||
await fs.writeFile(this.filePath, safeStorage.encryptString(key), { mode: 0o600 })
|
||||
await fs.chmod(this.filePath, 0o600)
|
||||
const filePath = this.filePath(accountRoot)
|
||||
await fs.ensureDir(this.directoryPath)
|
||||
await fs.writeFile(filePath, safeStorage.encryptString(key), { mode: 0o600 })
|
||||
await fs.chmod(filePath, 0o600)
|
||||
return { success: true, key, saved: true, encryptionAvailable: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
saved: await fs.pathExists(this.filePath),
|
||||
saved: await fs.pathExists(this.filePath(accountRoot)),
|
||||
encryptionAvailable: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async clear(): Promise<{ success: boolean; error?: string }> {
|
||||
async clear(accountRoot: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
await fs.remove(this.filePath)
|
||||
if (accountRoot) await fs.remove(this.filePath(accountRoot))
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Message } from '../shared/types'
|
||||
|
||||
export const exportStyles = `:root{color-scheme:light;--page:#edf2f0;--panel:#fff;--text:#1d2a25;--muted:#68766f;--border:#d8e2dc;--mine:#d9f0e2;--accent:#176b57}*{box-sizing:border-box}body{margin:0;background:var(--page);color:var(--text);font:14px system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}.page{max-width:1240px;height:100vh;margin:auto;padding:22px 28px;display:flex;flex-direction:column}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:20px;background:var(--panel);border:1px solid var(--border);border-radius:18px;padding:18px 24px;box-shadow:0 8px 24px #29483b12}.title{font-size:18px;font-weight:750}.meta{color:var(--muted);margin-left:12px;font-size:13px}.controls{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:0}.controls input,.controls button{border:1px solid var(--border);border-radius:10px;padding:9px 12px;background:#fff;font:inherit}.controls input[type=search]{width:260px}.controls input[type=datetime-local],.controls #jump{display:none}.controls button{background:var(--accent);border-color:var(--accent);color:#fff;cursor:pointer}.count{margin-left:8px;color:var(--muted);font-size:13px}.scroll{margin-top:18px;overflow:auto;flex:1;padding:10px 6px 30px;display:flex;flex-direction:column;align-items:center}.message{display:flex;flex-direction:column;gap:6px;width:min(100%,820px);margin:0 0 22px}.message.hidden{display:none}.message.sent{align-items:flex-end;margin-left:auto}.message.system{align-items:center;width:min(100%,820px)}.message.system .row{justify-content:center}.message.system .avatar{display:none}.message.system .bubble{max-width:92%;padding:5px 10px;border:0;border-radius:5px;background:#e9eeeb;color:var(--muted);font-size:11px;text-align:center;box-shadow:none}.message.system .sender{display:none}.time{color:var(--muted);font-size:11px;margin:0 12px}.row{display:flex;gap:12px;align-items:flex-end}.sent .row{flex-direction:row-reverse}.avatar{width:38px;height:38px;flex:0 0 auto;border-radius:50%;overflow:hidden;background:#dcebe4;display:grid;place-items:center}.avatar img{width:100%;height:100%;object-fit:cover}.bubble{max-width:min(78%,760px);padding:13px 15px;border:1px solid var(--border);border-radius:10px 18px 18px 18px;background:#fff;box-shadow:0 4px 12px #29483b0d}.sent .bubble{background:var(--mine);border-color:#c7e6d4;border-radius:18px 10px 18px 18px}.sender{color:var(--muted);font-size:12px;margin-bottom:5px}.content{line-height:1.7;word-break:break-word;white-space:pre-wrap}.audio-wrap{width:260px;min-width:260px}.audio{display:block;width:260px;height:38px}.quote-reference{margin-top:10px;padding:8px 11px;border-left:3px solid #8eb4a3;background:#f1f6f3;color:var(--muted);display:grid;gap:3px}.quote-reference strong{font-weight:650;color:var(--text)}.quote-reference span{white-space:pre-wrap}.media-image{display:block;max-width:100%;max-height:360px;border-radius:12px;object-fit:contain;background:#eef2f5;cursor:zoom-in}.lightbox{position:fixed;inset:0;display:none;place-items:center;background:#14231ddd;z-index:10;padding:24px;overflow:auto}.lightbox.open{display:grid}.lightbox img{width:min(86vw,980px);max-height:88vh;object-fit:contain;cursor:zoom-in;transform:scale(var(--zoom,1));transform-origin:center;transition:transform .12s ease}`
|
||||
export const exportStyles = `:root{color-scheme:light;--page:#edf2f0;--panel:#fff;--text:#1d2a25;--muted:#68766f;--border:#d8e2dc;--mine:#d9f0e2;--accent:#176b57}*{box-sizing:border-box}body{margin:0;background:var(--page);color:var(--text);font:14px system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}.page{max-width:1240px;height:100vh;margin:auto;padding:22px 28px;display:flex;flex-direction:column}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:20px;background:var(--panel);border:1px solid var(--border);border-radius:18px;padding:18px 24px;box-shadow:0 8px 24px #29483b12}.title{font-size:18px;font-weight:750}.meta{color:var(--muted);margin-left:12px;font-size:13px}.controls{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:0}.controls input,.controls button{border:1px solid var(--border);border-radius:10px;padding:9px 12px;background:#fff;font:inherit}.controls input[type=search]{width:260px}.controls input[type=datetime-local],.controls #jump{display:none}.controls button{background:var(--accent);border-color:var(--accent);color:#fff;cursor:pointer}.count{margin-left:8px;color:var(--muted);font-size:13px}.scroll{margin-top:18px;overflow:auto;flex:1;padding:10px 6px 30px;display:flex;flex-direction:column;align-items:center}.message{display:flex;flex-direction:column;gap:6px;width:min(100%,820px);margin:0 0 22px}.message.hidden{display:none}.message.sent{align-items:flex-end;margin-left:auto}.message.system{align-items:center;width:min(100%,820px)}.message.system .row{justify-content:center}.message.system .avatar{display:none}.message.system .bubble{max-width:92%;padding:5px 10px;border:0;border-radius:5px;background:#e9eeeb;color:var(--muted);font-size:11px;text-align:center;box-shadow:none}.message.system .sender{display:none}.time{color:var(--muted);font-size:11px;margin:0 12px}.row{display:flex;gap:12px;align-items:flex-end}.sent .row{flex-direction:row-reverse}.avatar{width:38px;height:38px;flex:0 0 auto;border-radius:50%;overflow:hidden;background:#dcebe4;display:grid;place-items:center}.avatar img{width:100%;height:100%;object-fit:cover}.bubble{max-width:min(78%,760px);padding:13px 15px;border:1px solid var(--border);border-radius:10px 18px 18px 18px;background:#fff;box-shadow:0 4px 12px #29483b0d}.sent .bubble{background:var(--mine);border-color:#c7e6d4;border-radius:18px 10px 18px 18px}.sender{color:var(--muted);font-size:12px;margin-bottom:5px}.content{line-height:1.7;word-break:break-word;white-space:pre-wrap}.audio-wrap{width:260px;min-width:260px}.audio{display:block;width:260px;height:38px}.media-status{margin-top:8px;padding:6px 8px;border-left:3px solid #b27a18;background:#fff8e8;color:#79530f;font-size:12px;line-height:1.5}.quote-reference{margin-top:10px;padding:8px 11px;border-left:3px solid #8eb4a3;background:#f1f6f3;color:var(--muted);display:grid;gap:3px}.quote-reference strong{font-weight:650;color:var(--text)}.quote-reference span{white-space:pre-wrap}.media-image{display:block;max-width:100%;max-height:360px;border-radius:12px;object-fit:contain;background:#eef2f5;cursor:zoom-in}.lightbox{position:fixed;inset:0;display:none;place-items:center;background:#14231ddd;z-index:10;padding:24px;overflow:auto}.lightbox.open{display:grid}.lightbox img{width:min(86vw,980px);max-height:88vh;object-fit:contain;cursor:zoom-in;transform:scale(var(--zoom,1));transform-origin:center;transition:transform .12s ease}.lightbox-close{position:fixed;top:20px;right:20px;z-index:11;width:42px;height:42px;border:1px solid #ffffff66;border-radius:50%;background:#14231dcc;color:#fff;font-size:30px;line-height:1;cursor:pointer}`
|
||||
const safe = (value: unknown): string =>
|
||||
String(value ?? '').replace(
|
||||
/[&<>"']/g,
|
||||
@@ -14,7 +14,10 @@ export function renderExportPage(name: string, messages: Message[]): string {
|
||||
? `<img src="${safe(m.img)}" alt="">`
|
||||
: safe((m.name || (m.isSender ? '我' : '友')).slice(0, 1))
|
||||
const audio = m.voiceDataUrl
|
||||
? `<div class="audio-wrap"><audio class="audio" controls preload="metadata" src="${m.voiceDataUrl}"></audio></div>`
|
||||
? `<div class="audio-wrap"><audio class="audio" controls preload="metadata" src="${safe(m.voiceDataUrl)}"></audio></div>`
|
||||
: ''
|
||||
const mediaStatus = m.exportMediaError
|
||||
? `<div class="media-status">${safe(m.exportMediaError)}</div>`
|
||||
: ''
|
||||
const quote =
|
||||
m.contentData?.type === 'quote'
|
||||
@@ -34,8 +37,8 @@ export function renderExportPage(name: string, messages: Message[]): string {
|
||||
: `<div class="avatar">${m.exportAvatarUrl ? `<img src="${safe(m.exportAvatarUrl)}" alt="">` : avatar}</div>`
|
||||
const isPat = m.contentData?.type === 'system' && m.contentData.pat
|
||||
const text = m.content || (m.contentData?.type === 'quote' ? m.contentData.title : '')
|
||||
return `<article class="message${m.isSender ? ' sent' : ''}${isPat ? ' system' : ''}" data-time="${m.createTime || 0}" data-search="${safe(`${m.name || ''} ${m.content || ''} ${m.type}`.toLowerCase())}"><div class="time">${safe(m.datetime)}</div><div class="row">${isPat ? '' : avatarMarkup}<div class="bubble"><div class="sender">${isPat ? '' : safe(m.name || (m.isSender ? '我' : '联系人'))}</div>${media}${audio}${quote}<div class="content">${safe(text || (!media && !audio && !quote ? `[${m.type}]` : ''))}</div></div></div></article>`
|
||||
return `<article class="message${m.isSender ? ' sent' : ''}${isPat ? ' system' : ''}" data-time="${m.createTime || 0}" data-search="${safe(`${m.name || ''} ${m.content || ''} ${m.type}`.toLowerCase())}"><div class="time">${safe(m.datetime)}</div><div class="row">${isPat ? '' : avatarMarkup}<div class="bubble"><div class="sender">${isPat ? '' : safe(m.name || (m.isSender ? '我' : '联系人'))}</div>${media}${audio}${quote}<div class="content">${safe(text || (!media && !audio && !quote ? `[${m.type}]` : ''))}</div>${mediaStatus}</div></div></article>`
|
||||
})
|
||||
.join('')
|
||||
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${safe(name)} - 聊天记录</title><style>${exportStyles}</style></head><body><main class="page"><header class="toolbar"><div><span class="title">${safe(name)}</span><span class="meta">${messages.length.toLocaleString()} 条消息</span></div><div class="controls"><input id="query" type="search" placeholder="搜索消息..."><input id="point" type="datetime-local"><button id="jump">跳转</button><span class="count" id="count"></span></div></header><section class="scroll" id="messages">${body}</section></main><div class="lightbox" id="lightbox"><img id="lightbox-image" alt="预览"></div><script>(()=>{const all=[...document.querySelectorAll('.message')],q=document.querySelector('#query'),d=document.querySelector('#point'),c=document.querySelector('#count'),box=document.querySelector('#lightbox'),preview=document.querySelector('#lightbox-image');let zoom=1;const updateZoom=()=>preview.style.setProperty('--zoom',zoom);const update=()=>{const term=q.value.trim().toLowerCase(),at=d.value?new Date(d.value).getTime()/1000:0;let n=0;all.forEach(x=>{const ok=(!term||x.dataset.search.includes(term))&&(!at||Number(x.dataset.time)>=at);x.classList.toggle('hidden',!ok);if(ok)n++});c.textContent='共 '+n+' 条'};q.addEventListener('input',update);d.addEventListener('change',update);document.querySelector('#jump').onclick=()=>{const at=d.value?new Date(d.value).getTime()/1000:0;all.find(x=>Number(x.dataset.time)>=at)?.scrollIntoView({behavior:'smooth',block:'center'})};document.querySelectorAll('.media-image').forEach(image=>image.addEventListener('click',()=>{if(image.tagName==='IMG'){preview.src=image.src;zoom=1;updateZoom();box.classList.add('open')}}));preview.addEventListener('wheel',event=>{event.preventDefault();zoom=Math.min(5,Math.max(.5,zoom+(event.deltaY<0?.2:-.2)));updateZoom()},{passive:false});preview.addEventListener('dblclick',()=>{zoom=1;updateZoom()});box.addEventListener('click',event=>{if(event.target===box)box.classList.remove('open')});update()})()</script></body></html>`
|
||||
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${safe(name)} - 聊天记录</title><style>${exportStyles}</style></head><body><main class="page"><header class="toolbar"><div><span class="title">${safe(name)}</span><span class="meta">${messages.length.toLocaleString()} 条消息</span></div><div class="controls"><input id="query" type="search" placeholder="搜索消息..."><input id="point" type="datetime-local"><button id="jump">跳转</button><span class="count" id="count"></span></div></header><section class="scroll" id="messages">${body}</section></main><div class="lightbox" id="lightbox"><button class="lightbox-close" id="lightbox-close" type="button" aria-label="关闭图片预览">×</button><img id="lightbox-image" alt="预览"></div><script>(()=>{const all=[...document.querySelectorAll('.message')],q=document.querySelector('#query'),d=document.querySelector('#point'),c=document.querySelector('#count'),box=document.querySelector('#lightbox'),preview=document.querySelector('#lightbox-image'),closeButton=document.querySelector('#lightbox-close');let zoom=1;const updateZoom=()=>preview.style.setProperty('--zoom',zoom);const closeLightbox=()=>{box.classList.remove('open');zoom=1;updateZoom()};const update=()=>{const term=q.value.trim().toLowerCase(),at=d.value?new Date(d.value).getTime()/1000:0;let n=0;all.forEach(x=>{const ok=(!term||x.dataset.search.includes(term))&&(!at||Number(x.dataset.time)>=at);x.classList.toggle('hidden',!ok);if(ok)n++});c.textContent='共 '+n+' 条'};q.addEventListener('input',update);d.addEventListener('change',update);document.querySelector('#jump').onclick=()=>{const at=d.value?new Date(d.value).getTime()/1000:0;all.find(x=>Number(x.dataset.time)>=at)?.scrollIntoView({behavior:'smooth',block:'center'})};document.querySelectorAll('.media-image').forEach(image=>image.addEventListener('click',()=>{if(image.tagName==='IMG'){preview.src=image.src;zoom=1;updateZoom();box.classList.add('open')}}));preview.addEventListener('wheel',event=>{event.preventDefault();zoom=Math.min(5,Math.max(.5,zoom+(event.deltaY<0?.2:-.2)));updateZoom()},{passive:false});preview.addEventListener('dblclick',()=>{zoom=1;updateZoom()});box.addEventListener('click',event=>{if(event.target===box)closeLightbox()});closeButton.addEventListener('click',closeLightbox);document.addEventListener('keydown',event=>{if(event.key==='Escape')closeLightbox()});update()})()</script></body></html>`
|
||||
}
|
||||
|
||||
+109
-18
@@ -16,6 +16,7 @@ import { ImageDecryptService } from './image-decrypt-service'
|
||||
import { ImageKeyConfigService } from './services/image-key-config-service'
|
||||
import { VideoAssetService } from './video-asset-service'
|
||||
import { StickerService } from './sticker-service'
|
||||
import { getImageExportAttempts } from '../shared/export-media'
|
||||
|
||||
const jobs = new Set<string>()
|
||||
const safeFilePart = (value: string): string =>
|
||||
@@ -26,6 +27,10 @@ const exportStamp = (): string => {
|
||||
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
|
||||
}
|
||||
const imageKeys = new ImageKeyConfigService()
|
||||
|
||||
const keepMediaError = (request: ExportRequest, message: Message, error: string): void => {
|
||||
if (request.keepMissing !== false) message.exportMediaError = error
|
||||
}
|
||||
function decodeDataUrl(data: string): { extension: string; buffer: Buffer } | null {
|
||||
const match = /^data:([^;]+);base64,(.+)$/s.exec(data)
|
||||
if (!match) return null
|
||||
@@ -105,11 +110,20 @@ function render(format: ExportRequest['format'], messages: Message[], name: stri
|
||||
if (format === 'json')
|
||||
return JSON.stringify({ name, exportedAt: new Date().toISOString(), messages }, null, 2)
|
||||
if (format === 'markdown')
|
||||
return `# ${name}\n\n${messages.map((m) => `**${m.name || (m.isSender ? '我' : '联系人')}** · ${m.datetime}\n\n${m.content || `[${m.type}]`}\n`).join('\n')}`
|
||||
return `# ${name}\n\n${messages.map((m) => `**${m.name || (m.isSender ? '我' : '联系人')}** · ${m.datetime}\n\n${m.content || `[${m.type}]`}${m.exportMediaUrl || m.voiceDataUrl || m.exportMediaError ? `\n\n媒体:${m.exportMediaUrl || m.voiceDataUrl || m.exportMediaError}` : ''}\n`).join('\n')}`
|
||||
return [
|
||||
'时间,发送者,类型,内容',
|
||||
'时间,发送者,类型,内容,媒体路径,媒体状态',
|
||||
...messages.map((m) =>
|
||||
[m.datetime, m.name || (m.isSender ? '我' : '联系人'), m.type, m.content].map(csv).join(',')
|
||||
[
|
||||
m.datetime,
|
||||
m.name || (m.isSender ? '我' : '联系人'),
|
||||
m.type,
|
||||
m.content,
|
||||
m.exportMediaUrl || m.voiceDataUrl || '',
|
||||
m.exportMediaError || ''
|
||||
]
|
||||
.map(csv)
|
||||
.join(',')
|
||||
)
|
||||
].join('\n')
|
||||
}
|
||||
@@ -126,9 +140,19 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
.listMessages(request.userMd5, request.startTime, request.endTime)
|
||||
.filter((m) => request.kinds.includes(kindOf(m)))
|
||||
for (const message of messages) {
|
||||
message.exportMediaUrl = undefined
|
||||
message.exportMediaType = undefined
|
||||
message.exportMediaError = undefined
|
||||
message.voiceDataUrl = undefined
|
||||
message.exportShowAvatar = request.includeAvatars !== false
|
||||
const mappedName = message.senderId ? request.nameMap?.[message.senderId] : undefined
|
||||
if (mappedName) message.name = mappedName
|
||||
if (
|
||||
request.format !== 'html' &&
|
||||
['image', 'video', 'voice', 'sticker'].includes(kindOf(message))
|
||||
) {
|
||||
message.exportMediaError = '当前导出格式记录媒体状态,但不复制媒体文件'
|
||||
}
|
||||
}
|
||||
send({ jobId: request.jobId, phase: 'reading', processed: 10, total: 100, percent: 10 })
|
||||
if (!jobs.has(request.jobId)) {
|
||||
@@ -181,25 +205,46 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
: null
|
||||
if (voiceService) {
|
||||
for (const [index, message] of messages.entries()) {
|
||||
if (
|
||||
kindOf(message) !== 'voice' ||
|
||||
!message.sessionId ||
|
||||
!message.localId ||
|
||||
!message.createTime
|
||||
)
|
||||
if (kindOf(message) !== 'voice') continue
|
||||
if (!message.sessionId || message.localId == null || !message.createTime) {
|
||||
keepMediaError(request, message, '语音标识不完整,无法定位本地语音')
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const voice = await voiceService.resolveVoice(
|
||||
message.sessionId,
|
||||
message.localId,
|
||||
message.createTime,
|
||||
message.serverId
|
||||
)
|
||||
if (!voice.success || !voice.data) continue
|
||||
if (!voice.success || !voice.data) {
|
||||
const detail = voice.error || '未知原因'
|
||||
const reason = /未找到|不存在|获取语音数据失败/.test(detail)
|
||||
? `语音文件缺失:${detail}`
|
||||
: /Silk|解码|数据为空/.test(detail)
|
||||
? `语音解析失败:${detail}`
|
||||
: `语音格式不支持或读取失败:${detail}`
|
||||
keepMediaError(request, message, reason)
|
||||
continue
|
||||
}
|
||||
const voiceName = `voice_${index + 1}_${message.localId}.wav`
|
||||
const audioBuffer = Buffer.from(voice.data, 'base64')
|
||||
await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer)
|
||||
message.voiceDataUrl = `voices/${voiceName}`
|
||||
message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2)))
|
||||
} catch (error) {
|
||||
keepMediaError(
|
||||
request,
|
||||
message,
|
||||
`语音文件写入失败:${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (request.includeMedia) {
|
||||
for (const message of messages) {
|
||||
if (kindOf(message) === 'voice') {
|
||||
keepMediaError(request, message, '数据库未连接,无法读取本地语音')
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [index, message] of messages.entries()) {
|
||||
@@ -228,35 +273,79 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (message.contentData.type === 'image' && imageService) {
|
||||
if (message.contentData.type === 'image') {
|
||||
if (!imageService) {
|
||||
keepMediaError(request, message, '未配置图片解密密钥,无法导出图片')
|
||||
} else {
|
||||
let fileFound = false
|
||||
let decryptedImage: { data: string; filePath: string } | null = null
|
||||
let usedFallback = false
|
||||
for (const attempt of getImageExportAttempts(request)) {
|
||||
const file = imageService.findImageFile(
|
||||
message.contentData.md5,
|
||||
message.contentData.datName,
|
||||
{ allowThumbnail: true }
|
||||
{
|
||||
allowThumbnail: attempt.allowThumbnail,
|
||||
preferThumbnail: attempt.preferThumbnail,
|
||||
sessionId: message.sessionId
|
||||
}
|
||||
)
|
||||
const decrypted = file ? imageService.decryptImageToBase64WithFallback(file, true) : null
|
||||
const decoded = decrypted ? decodeDataUrl(decrypted.data) : null
|
||||
if (!file) continue
|
||||
fileFound = true
|
||||
const decrypted = imageService.decryptImageToBase64WithFallback(
|
||||
file,
|
||||
attempt.allowThumbnail
|
||||
)
|
||||
if (!decrypted) continue
|
||||
decryptedImage = decrypted
|
||||
usedFallback = attempt.fallback || imageService.isThumbnailFile(decrypted.filePath)
|
||||
break
|
||||
}
|
||||
const decoded = decryptedImage ? decodeDataUrl(decryptedImage.data) : null
|
||||
if (decoded) {
|
||||
const name = `image_${index + 1}.${decoded.extension}`
|
||||
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
|
||||
message.exportMediaUrl = `media/${name}`
|
||||
message.exportMediaType = 'image'
|
||||
if (usedFallback) {
|
||||
keepMediaError(request, message, '原图不可用,已降级使用缩略图')
|
||||
}
|
||||
} else if (message.contentData.type === 'video' && videoService) {
|
||||
} else if (!fileFound) {
|
||||
keepMediaError(
|
||||
request,
|
||||
message,
|
||||
request.fallbackThumbnail === false
|
||||
? '原图文件缺失,未启用缩略图降级'
|
||||
: '原图和缩略图文件均缺失'
|
||||
)
|
||||
} else {
|
||||
keepMediaError(request, message, '图片解析失败或当前格式不支持')
|
||||
}
|
||||
}
|
||||
} else if (message.contentData.type === 'video') {
|
||||
const hashes = [
|
||||
message.contentData.md5,
|
||||
message.contentData.newMd5,
|
||||
message.contentData.rawMd5
|
||||
].filter((value): value is string => Boolean(value))
|
||||
if (!videoService) {
|
||||
keepMediaError(request, message, '数据库未连接,无法定位本地视频')
|
||||
} else if (hashes.length === 0) {
|
||||
keepMediaError(request, message, '视频标识不完整,无法定位本地视频')
|
||||
} else {
|
||||
const resolved = videoService.resolve(hashes)
|
||||
const token = resolved.url?.split('/').pop()
|
||||
const source = token ? videoService.pathForToken(token) : undefined
|
||||
if (source) {
|
||||
const source = resolved.url ? videoService.pathForUrl(resolved.url) : undefined
|
||||
if (!resolved.success || !source) {
|
||||
keepMediaError(request, message, resolved.error || '视频文件缺失或已移动')
|
||||
} else if (extname(source).toLowerCase() !== '.mp4') {
|
||||
keepMediaError(request, message, '视频格式不支持,仅支持本地 MP4 文件')
|
||||
} else {
|
||||
const name = `video_${index + 1}.mp4`
|
||||
await fs.copyFile(source, join(outputDir, 'media', name))
|
||||
message.exportMediaUrl = `media/${name}`
|
||||
message.exportMediaType = 'video'
|
||||
}
|
||||
}
|
||||
} else if (message.contentData.type === 'sticker' && stickerService) {
|
||||
const stickerSource = message.contentData.url || message.contentData.thumbUrl
|
||||
const result = await stickerService.resolveSticker(stickerSource, message.contentData.md5)
|
||||
@@ -270,6 +359,8 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
|
||||
message.exportMediaUrl = `media/${name}`
|
||||
message.exportMediaType = 'sticker'
|
||||
} else {
|
||||
keepMediaError(request, message, result.error || '表情资源缺失或下载失败')
|
||||
}
|
||||
}
|
||||
send({
|
||||
|
||||
+84
-18
@@ -58,13 +58,25 @@ import * as chat from './services/chat-service'
|
||||
import { apiServer } from './http-server'
|
||||
import { skillResourceService } from './services/skill-resource-service'
|
||||
import { testLocalApiRequest } from './services/local-api-test-service'
|
||||
import { isWindowsWechatRunning } from './services/wechat-process-status'
|
||||
import { isWechatRunning } from './services/wechat-process-status'
|
||||
import {
|
||||
inspectImageDecryptionStatus,
|
||||
testImageDecryption
|
||||
} from './services/image-decryption-status-service'
|
||||
import type { SaveImageKeyRequest, TestImageDecryptionRequest } from '../shared/image-decryption'
|
||||
import { loadSettings, saveSettings, getSettingsPath, AppSettings } from './services/settings-store'
|
||||
import {
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
getSettingsPath,
|
||||
AppSettings,
|
||||
validateDbRoot
|
||||
} from './services/settings-store'
|
||||
import {
|
||||
detectDataStructureVersion,
|
||||
detectWechatVersion,
|
||||
getOsVersionLabel
|
||||
} from './services/connection-diagnostics'
|
||||
import { buildSafeDiagnosticSummary } from '../shared/connection-diagnostics'
|
||||
import {
|
||||
flushBootstrapCacheWritesSync,
|
||||
getBootstrapCache,
|
||||
@@ -88,6 +100,7 @@ import { configureRecallArchive, RecallArchiveMonitor } from './services/recall-
|
||||
import { VideoAssetService } from './video-asset-service'
|
||||
import { cancelExport, revealExport, runExport } from './export-service'
|
||||
import type { ExportRequest } from '../shared/export'
|
||||
import { discoverAccounts } from './services/account-discovery'
|
||||
|
||||
// electron-vite can close the child's stdout/stderr after spawning Electron.
|
||||
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
|
||||
@@ -479,7 +492,7 @@ app.whenReady().then(async () => {
|
||||
return clearCache(scope)
|
||||
})
|
||||
|
||||
ipcMain.handle('db:init', async (_, key: string) => {
|
||||
ipcMain.handle('db:init', async (_, key: string, accountRoot?: string) => {
|
||||
if (dbInitInFlight) return dbInitInFlight
|
||||
|
||||
dbInitInFlight = (async () => {
|
||||
@@ -489,17 +502,35 @@ app.whenReady().then(async () => {
|
||||
const trimmedKey = String(key || '').trim()
|
||||
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
|
||||
const settings = loadSettings()
|
||||
const selectedRoot = String(accountRoot || settings.dbRoot || '').trim()
|
||||
const rootValidation = validateDbRoot(selectedRoot)
|
||||
if (!rootValidation.valid) {
|
||||
return {
|
||||
success: false,
|
||||
code: 'ROOT_UNAVAILABLE',
|
||||
error: rootValidation.error,
|
||||
monitoring: false
|
||||
}
|
||||
}
|
||||
if (!existsSync(join(selectedRoot, 'db_storage'))) {
|
||||
return {
|
||||
success: false,
|
||||
code: 'ACCOUNT_SELECTION_REQUIRED',
|
||||
error: '请先明确选择一个微信账号',
|
||||
monitoring: false
|
||||
}
|
||||
}
|
||||
if (
|
||||
chat.isReady() &&
|
||||
chat.getCurrentKey().replace(/^0x/i, '').trim() === trimmedKey.replace(/^0x/i, '') &&
|
||||
(!settings.dbRoot || chat.getCurrentAccountRoot() === settings.dbRoot)
|
||||
chat.getCurrentAccountRoot() === selectedRoot
|
||||
) {
|
||||
console.log('[WCDB4] db:init reuse current connection')
|
||||
return { success: true, monitoring: true }
|
||||
}
|
||||
const nextWechatDb = await WechatDb.create(key, settings.dbRoot)
|
||||
const nextWechatDb = await WechatDb.create(key, selectedRoot)
|
||||
const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot()
|
||||
if (resolvedRoot && resolvedRoot !== settings.dbRoot) {
|
||||
if (resolvedRoot) {
|
||||
// 同步更新 imageKeyRoot,避免自动获取图片密钥时扫描到错误目录
|
||||
saveSettings({
|
||||
...settings,
|
||||
@@ -543,19 +574,43 @@ app.whenReady().then(async () => {
|
||||
return dbInitInFlight
|
||||
})
|
||||
|
||||
ipcMain.handle('key:getSavedDbKey', async () => databaseKeyStore.load())
|
||||
ipcMain.handle('accounts:discover', (_, inputPath: string) =>
|
||||
discoverAccounts(inputPath, databaseKeyStore, chat.getCurrentAccountRoot())
|
||||
)
|
||||
|
||||
ipcMain.handle('key:getSavedDbKey', async (_, accountRoot: string) => {
|
||||
const selectedRoot = String(accountRoot || '').trim()
|
||||
const scoped = await databaseKeyStore.load(selectedRoot)
|
||||
if (scoped.saved || !selectedRoot) return scoped
|
||||
const legacy = await databaseKeyStore.loadLegacy()
|
||||
if (!legacy.success || !legacy.key) return scoped
|
||||
const validation = await chat.testConnection(legacy.key, selectedRoot)
|
||||
if (!validation.success) return scoped
|
||||
const migrated = await databaseKeyStore.save(selectedRoot, legacy.key)
|
||||
if (migrated.success) await databaseKeyStore.clearLegacy()
|
||||
return migrated
|
||||
})
|
||||
|
||||
ipcMain.handle('key:getEnvironment', async () => {
|
||||
const storage = await databaseKeyStore.getStatus()
|
||||
const storage = await databaseKeyStore.getStatus(
|
||||
chat.getCurrentAccountRoot() || loadSettings().dbRoot
|
||||
)
|
||||
const self = chat.getSelfAccountInfo()
|
||||
return {
|
||||
const settings = loadSettings()
|
||||
const environment = {
|
||||
platform: process.platform,
|
||||
osVersion: getOsVersionLabel(),
|
||||
appVersion: `v${app.getVersion()}`,
|
||||
wechatVersion: await detectWechatVersion(),
|
||||
dataStructureVersion: detectDataStructureVersion(settings.dbRoot),
|
||||
dataDirectoryDetected: validateDbRoot(settings.dbRoot).valid,
|
||||
autoDetectSupported: process.platform === 'win32',
|
||||
wechatRunning: await isWindowsWechatRunning(),
|
||||
wechatRunning: await isWechatRunning(),
|
||||
accountIdentified: Boolean(self?.wxid),
|
||||
dbConnected: chat.isReady(),
|
||||
encryptionAvailable: storage.encryptionAvailable
|
||||
}
|
||||
return { ...environment, diagnosticSummary: buildSafeDiagnosticSummary(environment) }
|
||||
})
|
||||
|
||||
ipcMain.handle('key:readClipboardDbKey', () => {
|
||||
@@ -566,18 +621,22 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('key:pasteAndSaveDbKey', async () => {
|
||||
ipcMain.handle('key:pasteAndSaveDbKey', async (_, accountRoot: string) => {
|
||||
const clipboardKey = clipboard.readText().trim()
|
||||
return databaseKeyStore.save(clipboardKey)
|
||||
return databaseKeyStore.save(String(accountRoot || ''), clipboardKey)
|
||||
})
|
||||
|
||||
ipcMain.handle('key:saveDbKey', async (_, key: string) =>
|
||||
databaseKeyStore.save(String(key || ''))
|
||||
ipcMain.handle('key:saveDbKey', async (_, accountRoot: string, key: string) =>
|
||||
databaseKeyStore.save(String(accountRoot || ''), String(key || ''))
|
||||
)
|
||||
|
||||
ipcMain.handle('key:clearSavedDbKey', async () => databaseKeyStore.clear())
|
||||
ipcMain.handle('key:clearSavedDbKey', async (_, accountRoot: string) =>
|
||||
databaseKeyStore.clear(String(accountRoot || ''))
|
||||
)
|
||||
|
||||
ipcMain.handle('key:autoGetDbKey', async (event, options?: { save?: boolean }) => {
|
||||
ipcMain.handle(
|
||||
'key:autoGetDbKey',
|
||||
async (event, accountRoot: string, options?: { save?: boolean }) => {
|
||||
const onStatus = (message: string): void => {
|
||||
if (!event.sender.isDestroyed()) event.sender.send('key:dbKeyStatus', { message })
|
||||
}
|
||||
@@ -587,15 +646,22 @@ app.whenReady().then(async () => {
|
||||
: await keyServiceMac.autoGetDbKey(onStatus)
|
||||
if (!result.success || !result.key) return result
|
||||
|
||||
const selectedRoot = String(accountRoot || '').trim()
|
||||
if (!selectedRoot) return { success: false, error: '请先选择微信账号' }
|
||||
const validation = await chat.testConnection(result.key, selectedRoot)
|
||||
if (!validation.success) {
|
||||
return { ...result, success: false, key: undefined, error: '获取到的密钥不属于所选账号' }
|
||||
}
|
||||
if (options?.save === false) return result
|
||||
|
||||
const saved = await databaseKeyStore.save(result.key)
|
||||
const saved = await databaseKeyStore.save(selectedRoot, result.key)
|
||||
return {
|
||||
...result,
|
||||
saved: saved.success,
|
||||
warning: saved.success ? undefined : saved.error
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('key:autoGetImageKey', async (event, options?: { save?: boolean }) => {
|
||||
const settings = loadSettings()
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import crypto from 'crypto'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import type { AccountDiscoveryResult, WechatAccountCandidate } from '../../shared/database-key'
|
||||
import { DatabaseKeyStore } from '../database-key-store'
|
||||
import { getBootstrapCache } from './bootstrap-cache'
|
||||
import { validateDbRoot } from './settings-store'
|
||||
|
||||
function accountId(accountRoot: string): string {
|
||||
return crypto.createHash('sha256').update(path.resolve(accountRoot).toLowerCase()).digest('hex')
|
||||
}
|
||||
|
||||
export async function discoverAccounts(
|
||||
inputPath: string,
|
||||
keyStore: DatabaseKeyStore,
|
||||
currentAccountRoot?: string
|
||||
): Promise<AccountDiscoveryResult> {
|
||||
const validation = validateDbRoot(inputPath)
|
||||
if (!validation.valid) return { success: false, accounts: [], error: validation.error }
|
||||
|
||||
const normalizedInput = path.resolve(inputPath)
|
||||
const isAccount = await fs.pathExists(path.join(normalizedInput, 'db_storage'))
|
||||
const roots = isAccount
|
||||
? [normalizedInput]
|
||||
: (await fs.readdir(normalizedInput, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => path.join(normalizedInput, entry.name))
|
||||
.filter((candidate) => fs.existsSync(path.join(candidate, 'db_storage')))
|
||||
|
||||
const accounts: WechatAccountCandidate[] = await Promise.all(
|
||||
roots.map(async (accountRoot) => {
|
||||
const cached = getBootstrapCache(accountRoot)?.self
|
||||
return {
|
||||
id: accountId(accountRoot),
|
||||
accountRoot,
|
||||
directoryName: path.basename(accountRoot),
|
||||
wxid: cached?.wxid,
|
||||
nickname: cached?.nickname,
|
||||
avatar: cached?.avatar,
|
||||
hasSavedDbKey: (await keyStore.getStatus(accountRoot)).saved,
|
||||
loginStatus: currentAccountRoot
|
||||
? path.resolve(currentAccountRoot).toLowerCase() ===
|
||||
path.resolve(accountRoot).toLowerCase()
|
||||
? 'current'
|
||||
: 'other'
|
||||
: 'unknown',
|
||||
selectedByInput: isAccount
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
inputKind: isAccount ? 'account' : 'root',
|
||||
accounts,
|
||||
preselectedAccountId: isAccount ? accounts[0]?.id : undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { execFile } from 'child_process'
|
||||
import fs from 'fs-extra'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { promisify } from 'util'
|
||||
import { isUsableDbRoot } from './settings-store'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
const platformLabel = (): string => {
|
||||
if (process.platform === 'win32') return `Windows ${os.release()} (${process.arch})`
|
||||
if (process.platform === 'darwin') return `macOS ${os.release()} (${process.arch})`
|
||||
return `${process.platform} ${os.release()} (${process.arch})`
|
||||
}
|
||||
|
||||
async function detectWindowsWechatVersion(): Promise<string> {
|
||||
const script = [
|
||||
'$process = Get-Process Weixin,WeChat -ErrorAction SilentlyContinue | Where-Object Path | Select-Object -First 1',
|
||||
'$candidate = if ($process) { $process.Path } else {',
|
||||
" @($env:ProgramFiles, ${env:ProgramFiles(x86)}) | Where-Object { $_ } | ForEach-Object { Join-Path $_ 'Tencent\\WeChat\\WeChat.exe' } | Where-Object { Test-Path $_ } | Select-Object -First 1",
|
||||
'}',
|
||||
'if ($candidate) { (Get-Item -LiteralPath $candidate).VersionInfo.ProductVersion }'
|
||||
].join('; ')
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'powershell.exe',
|
||||
['-NoProfile', '-NonInteractive', '-Command', script],
|
||||
{ timeout: 3000, windowsHide: true }
|
||||
)
|
||||
return stdout.trim() || '未检测到'
|
||||
} catch {
|
||||
return '未检测到'
|
||||
}
|
||||
}
|
||||
|
||||
async function detectMacWechatVersion(): Promise<string> {
|
||||
const candidates = [
|
||||
'/Applications/WeChat.app/Contents/Info',
|
||||
path.join(os.homedir(), 'Applications/WeChat.app/Contents/Info')
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
if (!fs.existsSync(`${candidate}.plist`)) continue
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'/usr/bin/defaults',
|
||||
['read', candidate, 'CFBundleShortVersionString'],
|
||||
{ timeout: 3000 }
|
||||
)
|
||||
if (stdout.trim()) return stdout.trim()
|
||||
} catch {
|
||||
// Continue to the next known installation location.
|
||||
}
|
||||
}
|
||||
return '未检测到'
|
||||
}
|
||||
|
||||
export async function detectWechatVersion(): Promise<string> {
|
||||
if (process.platform === 'win32') return detectWindowsWechatVersion()
|
||||
if (process.platform === 'darwin') return detectMacWechatVersion()
|
||||
return '未检测到'
|
||||
}
|
||||
|
||||
export function detectDataStructureVersion(dbRoot: string): string {
|
||||
return isUsableDbRoot(dbRoot) ? '微信 4.x(WCDB)' : '未检测到'
|
||||
}
|
||||
|
||||
export function getOsVersionLabel(): string {
|
||||
return platformLabel()
|
||||
}
|
||||
@@ -86,7 +86,7 @@ function unique(values: string[]): string[] {
|
||||
return Array.from(new Set(values))
|
||||
}
|
||||
|
||||
function isUsableDbRoot(candidate?: string): boolean {
|
||||
export function isUsableDbRoot(candidate?: string): boolean {
|
||||
if (!candidate || !fs.existsSync(candidate)) return false
|
||||
if (fs.existsSync(path.join(candidate, 'db_storage'))) return true
|
||||
try {
|
||||
@@ -98,6 +98,21 @@ function isUsableDbRoot(candidate?: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export function validateDbRoot(candidate?: string): { valid: boolean; error?: string } {
|
||||
const root = String(candidate || '').trim()
|
||||
if (!root) return { valid: false, error: '微信数据目录为空,请重新选择目录' }
|
||||
if (!fs.existsSync(root)) {
|
||||
return { valid: false, error: '微信数据目录不存在,请检查路径或重新选择目录' }
|
||||
}
|
||||
if (!isUsableDbRoot(root)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: '所选目录中未找到微信 4.x 数据库(db_storage),请选择 xwechat_files 或账号目录'
|
||||
}
|
||||
}
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
const defaultDbRoot = getDefaultDbRoot()
|
||||
|
||||
const DEFAULT_SETTINGS: AppSettings = {
|
||||
|
||||
@@ -938,9 +938,27 @@ export class Wcdb4Client {
|
||||
): Promise<Wcdb4Message[]> {
|
||||
const startedAt = Date.now()
|
||||
const maxRows = this.normalizeMessageLimit(options.limit)
|
||||
const messages = await this.getMessagesByCursorAsync(username, startTime, endTime, maxRows)
|
||||
let cursorMessages: Wcdb4Message[] = []
|
||||
try {
|
||||
cursorMessages = await this.getMessagesByCursorAsync(username, startTime, endTime, maxRows)
|
||||
} catch (error) {
|
||||
console.warn(`[WCDB4] async cursor messages failed username=${username}:`, error)
|
||||
}
|
||||
|
||||
if (endTime && (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery)) {
|
||||
throw new Error('当前数据服务无法检查历史消息分片,请更新应用或核对微信数据版本')
|
||||
}
|
||||
|
||||
// Older pages may live in message shards that the native cursor does not
|
||||
// enumerate. A bounded query must inspect all matching stores so history
|
||||
// cannot silently stop at a shard boundary.
|
||||
let tableMessages: Wcdb4Message[] = []
|
||||
if (endTime || cursorMessages.length === 0) {
|
||||
tableMessages = await this.getMessagesByTableScanAsync(username, startTime, endTime, maxRows)
|
||||
}
|
||||
const messages = this.mergeMessageRows(cursorMessages, tableMessages, maxRows)
|
||||
console.log(
|
||||
`[WCDB4] getMessages async username=${username} rows=${messages.length} cost=${Date.now() - startedAt}ms`
|
||||
`[WCDB4] getMessages async username=${username} rows=${messages.length} cursor=${cursorMessages.length} tables=${tableMessages.length} cost=${Date.now() - startedAt}ms`
|
||||
)
|
||||
return messages
|
||||
}
|
||||
@@ -998,6 +1016,71 @@ export class Wcdb4Client {
|
||||
return this.finalizeMessages(username, allRows, startTime, endTime, limit)
|
||||
}
|
||||
|
||||
private async getMessagesByTableScanAsync(
|
||||
username: string,
|
||||
startTime?: number,
|
||||
endTime?: number,
|
||||
limit?: number
|
||||
): Promise<Wcdb4Message[]> {
|
||||
if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return []
|
||||
|
||||
let tables: Wcdb4MessageStore[] = []
|
||||
try {
|
||||
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
|
||||
this.wcdbGetMessageTableStats as unknown as KoffiAsyncFunction,
|
||||
username
|
||||
)
|
||||
tables = (Array.isArray(rows) ? rows : [])
|
||||
.map((row) => ({
|
||||
tableName: this.pickString(row, ['table_name', 'tableName', 'name']),
|
||||
dbPath: this.pickString(row, ['db_path', 'dbPath', 'path'])
|
||||
}))
|
||||
.filter((row) => row.tableName && row.dbPath)
|
||||
} catch (error) {
|
||||
console.warn(`[WCDB4] async message table stats failed username=${username}:`, error)
|
||||
throw new Error(
|
||||
`无法读取历史消息分片信息:${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
|
||||
const begin = this.normalizeTimestamp(startTime || 0)
|
||||
const end = this.normalizeTimestamp(endTime || 0)
|
||||
const where = [
|
||||
begin > 0 ? `"create_time" >= ${begin}` : '',
|
||||
end > 0 ? `"create_time" <= ${end}` : ''
|
||||
].filter(Boolean)
|
||||
const whereSql = where.length ? ` WHERE ${where.join(' AND ')}` : ''
|
||||
const rowLimit = limit || 5000
|
||||
const order = limit ? 'DESC' : 'ASC'
|
||||
|
||||
const allRows: Record<string, unknown>[] = []
|
||||
let successfulTables = 0
|
||||
for (const table of tables) {
|
||||
try {
|
||||
const sql = `SELECT * FROM ${this.quoteSqlIdentifier(table.tableName)}${whereSql} ORDER BY "create_time" ${order} LIMIT ${rowLimit}`
|
||||
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
|
||||
this.wcdbExecQuery as unknown as KoffiAsyncFunction,
|
||||
'message',
|
||||
table.dbPath,
|
||||
sql
|
||||
)
|
||||
successfulTables += 1
|
||||
if (Array.isArray(rows)) allRows.push(...rows)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[WCDB4] async message table scan failed username=${username} db=${table.dbPath} table=${table.tableName}:`,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (tables.length > 0 && successfulTables === 0) {
|
||||
throw new Error('历史消息分片均读取失败,请检查数据目录或微信数据版本')
|
||||
}
|
||||
|
||||
return this.finalizeMessages(username, allRows, startTime, endTime, limit)
|
||||
}
|
||||
|
||||
installRecallJournal(usernames: string[]): { installed: number; failed: number } {
|
||||
const stores = new Map<string, Wcdb4MessageStore>()
|
||||
for (const username of this.uniq(usernames)) {
|
||||
|
||||
Vendored
+15
-7
@@ -11,7 +11,8 @@ import {
|
||||
import type {
|
||||
DatabaseKeyEnvironment,
|
||||
DatabaseKeyStorageResult,
|
||||
DatabaseKeyValidationResult
|
||||
DatabaseKeyValidationResult,
|
||||
AccountDiscoveryResult
|
||||
} from '../shared/database-key'
|
||||
import type {
|
||||
ImageDecoderSelectionResult,
|
||||
@@ -115,8 +116,10 @@ declare global {
|
||||
getCacheSummary: () => Promise<CacheSummary>
|
||||
clearCache: (scope: 'bootstrap' | 'electron' | 'all') => Promise<CacheSummary>
|
||||
initDb: (
|
||||
key: string
|
||||
key: string,
|
||||
accountRoot: string
|
||||
) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }>
|
||||
discoverAccounts: (inputPath: string) => Promise<AccountDiscoveryResult>
|
||||
getBootstrapCache: () => Promise<{
|
||||
self?: { wxid: string; nickname: string; avatar?: string; accountRoot: string }
|
||||
contacts: Contact[]
|
||||
@@ -239,14 +242,17 @@ declare global {
|
||||
) => Promise<SaveGeneratedReportResult>
|
||||
deleteGeneratedReport: (reportId: string) => Promise<DeleteGeneratedReportResult>
|
||||
revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }>
|
||||
getSavedDbKey: () => Promise<DatabaseKeyStorageResult>
|
||||
getSavedDbKey: (accountRoot: string) => Promise<DatabaseKeyStorageResult>
|
||||
getDatabaseKeyEnvironment: () => Promise<DatabaseKeyEnvironment>
|
||||
readDatabaseKeyClipboard: () => Promise<{
|
||||
success: boolean
|
||||
value?: string
|
||||
error?: string
|
||||
}>
|
||||
autoGetDbKey: (options?: { save?: boolean }) => Promise<{
|
||||
autoGetDbKey: (
|
||||
accountRoot: string,
|
||||
options?: { save?: boolean }
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
key?: string
|
||||
error?: string
|
||||
@@ -290,9 +296,11 @@ declare global {
|
||||
request: TestImageDecryptionRequest
|
||||
) => Promise<ImageDecryptionTestResult>
|
||||
clearImageKeyConfig: () => Promise<{ success: boolean; error?: string }>
|
||||
pasteAndSaveDbKey: () => Promise<{ success: boolean; key?: string; error?: string }>
|
||||
saveDbKey: (key: string) => Promise<DatabaseKeyStorageResult>
|
||||
clearSavedDbKey: () => Promise<{ success: boolean; error?: string }>
|
||||
pasteAndSaveDbKey: (
|
||||
accountRoot: string
|
||||
) => Promise<{ success: boolean; key?: string; error?: string }>
|
||||
saveDbKey: (accountRoot: string, key: string) => Promise<DatabaseKeyStorageResult>
|
||||
clearSavedDbKey: (accountRoot: string) => Promise<{ success: boolean; error?: string }>
|
||||
onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => () => void
|
||||
onDbKeyStatus: (callback: (payload: { message: string }) => void) => () => void
|
||||
onImageKeyStatus: (callback: (payload: { message: string }) => void) => () => void
|
||||
|
||||
+12
-6
@@ -21,6 +21,7 @@ import type { AppUpdateState } from '../shared/app-update'
|
||||
import type { CacheSummary } from '../shared/cache'
|
||||
import type { ExportRequest, ExportJobProgress } from '../shared/export'
|
||||
import type { ImageDecoderSelectionResult, ImageDecoderStatus } from '../shared/image-decryption'
|
||||
import type { AccountDiscoveryResult } from '../shared/database-key'
|
||||
|
||||
// 渲染器的自定义 API
|
||||
const api = {
|
||||
@@ -40,7 +41,9 @@ const api = {
|
||||
getCacheSummary: (): Promise<CacheSummary> => ipcRenderer.invoke('cache:getSummary'),
|
||||
clearCache: (scope: 'bootstrap' | 'electron' | 'all'): Promise<CacheSummary> =>
|
||||
ipcRenderer.invoke('cache:clear', scope),
|
||||
initDb: (key: string) => ipcRenderer.invoke('db:init', key),
|
||||
initDb: (key: string, accountRoot: string) => ipcRenderer.invoke('db:init', key, accountRoot),
|
||||
discoverAccounts: (inputPath: string): Promise<AccountDiscoveryResult> =>
|
||||
ipcRenderer.invoke('accounts:discover', inputPath),
|
||||
getBootstrapCache: () => ipcRenderer.invoke('db:getBootstrapCache'),
|
||||
getStartupCache: () => ipcRenderer.invoke('db:getStartupCache'),
|
||||
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
|
||||
@@ -98,10 +101,11 @@ const api = {
|
||||
deleteGeneratedReport: (reportId: string) =>
|
||||
ipcRenderer.invoke('report:deleteGenerated', reportId),
|
||||
revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath),
|
||||
getSavedDbKey: () => ipcRenderer.invoke('key:getSavedDbKey'),
|
||||
getSavedDbKey: (accountRoot: string) => ipcRenderer.invoke('key:getSavedDbKey', accountRoot),
|
||||
getDatabaseKeyEnvironment: () => ipcRenderer.invoke('key:getEnvironment'),
|
||||
readDatabaseKeyClipboard: () => ipcRenderer.invoke('key:readClipboardDbKey'),
|
||||
autoGetDbKey: (options?: { save?: boolean }) => ipcRenderer.invoke('key:autoGetDbKey', options),
|
||||
autoGetDbKey: (accountRoot: string, options?: { save?: boolean }) =>
|
||||
ipcRenderer.invoke('key:autoGetDbKey', accountRoot, options),
|
||||
autoGetImageKey: (options?: { save?: boolean }) =>
|
||||
ipcRenderer.invoke('key:autoGetImageKey', options),
|
||||
getImageKeyConfig: () => ipcRenderer.invoke('image:getConfig'),
|
||||
@@ -115,9 +119,11 @@ const api = {
|
||||
saveImageKeyConfig: (request) => ipcRenderer.invoke('image:saveConfig', request),
|
||||
testImageDecryption: (request) => ipcRenderer.invoke('image:testConfig', request),
|
||||
clearImageKeyConfig: () => ipcRenderer.invoke('image:clearConfig'),
|
||||
pasteAndSaveDbKey: () => ipcRenderer.invoke('key:pasteAndSaveDbKey'),
|
||||
saveDbKey: (key: string) => ipcRenderer.invoke('key:saveDbKey', key),
|
||||
clearSavedDbKey: () => ipcRenderer.invoke('key:clearSavedDbKey'),
|
||||
pasteAndSaveDbKey: (accountRoot: string) =>
|
||||
ipcRenderer.invoke('key:pasteAndSaveDbKey', accountRoot),
|
||||
saveDbKey: (accountRoot: string, key: string) =>
|
||||
ipcRenderer.invoke('key:saveDbKey', accountRoot, key),
|
||||
clearSavedDbKey: (accountRoot: string) => ipcRenderer.invoke('key:clearSavedDbKey', accountRoot),
|
||||
onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
|
||||
+246
-18
@@ -24,6 +24,7 @@ import { FirstUseWelcome } from './components/FirstUseWelcome'
|
||||
import { ExportWorkspace } from './components/export/ExportWorkspace'
|
||||
import { AISearchWorkspace } from './components/search/AISearchWorkspace'
|
||||
import type { ExportJobProgress, ExportRequest, ExportTaskRecord } from '../../shared/export'
|
||||
import type { DatabaseKeyEnvironment, WechatAccountCandidate } from '../../shared/database-key'
|
||||
import {
|
||||
getMessageIdentity,
|
||||
mergeMessagePages,
|
||||
@@ -32,6 +33,23 @@ import {
|
||||
|
||||
const SIDEBAR_MIN_WIDTH = 260
|
||||
const SIDEBAR_MAX_WIDTH = 380
|
||||
const DATABASE_CONNECT_TIMEOUT_MS = 30_000
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = window.setTimeout(() => reject(new Error(message)), timeoutMs)
|
||||
promise.then(
|
||||
(value) => {
|
||||
window.clearTimeout(timer)
|
||||
resolve(value)
|
||||
},
|
||||
(error) => {
|
||||
window.clearTimeout(timer)
|
||||
reject(error)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function getDevelopmentDatabaseKey(): string {
|
||||
if (!import.meta.env.DEV) return ''
|
||||
@@ -202,6 +220,7 @@ function App(): React.ReactElement {
|
||||
const [selectedContact, setSelectedContact] = useState<Contact | null>(null)
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [isMessagesLoading, setIsMessagesLoading] = useState(false)
|
||||
const [messageHistoryStatus, setMessageHistoryStatus] = useState<'idle' | 'end' | 'error'>('idle')
|
||||
const [filteredContacts, setFilteredContacts] = useState<Contact[]>([])
|
||||
const [contentFilter, setContentFilter] = useState('')
|
||||
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
|
||||
@@ -209,10 +228,16 @@ function App(): React.ReactElement {
|
||||
const [dbKeyStatusKind, setDbKeyStatusKind] = useState<'normal' | 'success' | 'error'>('normal')
|
||||
const [showDbKey, setShowDbKey] = useState(false)
|
||||
const [dbRootInput, setDbRootInput] = useState('')
|
||||
const [discoveredAccounts, setDiscoveredAccounts] = useState<WechatAccountCandidate[]>([])
|
||||
const [selectedAccountId, setSelectedAccountId] = useState('')
|
||||
const selectedAccount = discoveredAccounts.find((account) => account.id === selectedAccountId)
|
||||
const [showMacKeyFaq, setShowMacKeyFaq] = useState(false)
|
||||
const [databaseConnectionMode, setDatabaseConnectionMode] = useState<DatabaseConnectionMode>(
|
||||
getDevelopmentDatabaseKey() ? 'manual' : 'automatic'
|
||||
)
|
||||
const [connectionGuideStep, setConnectionGuideStep] = useState<1 | 2 | 3 | 4 | 5 | 6>(1)
|
||||
const [databaseEnvironment, setDatabaseEnvironment] = useState<DatabaseKeyEnvironment>()
|
||||
const connectionOperationRef = React.useRef(0)
|
||||
const [activePage, setActivePage] = useState<AppPage>('archive')
|
||||
const [archiveJumpTime, setArchiveJumpTime] = useState<number | null>(null)
|
||||
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database')
|
||||
@@ -278,6 +303,34 @@ function App(): React.ReactElement {
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
const refreshConnectionEnvironment = React.useCallback(async (): Promise<void> => {
|
||||
const root = dbRootInput.trim()
|
||||
try {
|
||||
if (root) {
|
||||
const discovery = await window.api.discoverAccounts(root)
|
||||
if (!discovery.success) throw new Error(discovery.error || '账号目录识别失败')
|
||||
setDiscoveredAccounts(discovery.accounts)
|
||||
setSelectedAccountId(discovery.preselectedAccountId || '')
|
||||
}
|
||||
const environment = await window.api.getDatabaseKeyEnvironment()
|
||||
setDatabaseEnvironment(environment)
|
||||
setDbKeyStatus('环境检查已更新')
|
||||
setDbKeyStatusKind('normal')
|
||||
} catch (error) {
|
||||
setDbKeyStatus(
|
||||
error instanceof Error ? `环境检查失败:${error.message}` : '环境检查失败,请重试'
|
||||
)
|
||||
setDbKeyStatusKind('error')
|
||||
}
|
||||
}, [dbRootInput])
|
||||
|
||||
React.useEffect(() => {
|
||||
void window.api
|
||||
.getDatabaseKeyEnvironment()
|
||||
.then(setDatabaseEnvironment)
|
||||
.catch(() => undefined)
|
||||
}, [])
|
||||
React.useEffect(() => {
|
||||
const loadAIConfig = async (): Promise<void> => {
|
||||
try {
|
||||
@@ -533,13 +586,26 @@ function App(): React.ReactElement {
|
||||
if (active && settingsResult.settings.dbRoot) {
|
||||
setDbRootInput(settingsResult.settings.dbRoot)
|
||||
}
|
||||
const discovery = settingsResult.settings.dbRoot
|
||||
? await window.api.discoverAccounts(settingsResult.settings.dbRoot)
|
||||
: { success: false, accounts: [] }
|
||||
if (active && discovery.success) {
|
||||
setDiscoveredAccounts(discovery.accounts)
|
||||
setSelectedAccountId(discovery.preselectedAccountId || '')
|
||||
}
|
||||
const startupAccountRoot = discovery.success
|
||||
? discovery.accounts.find((account) => account.id === discovery.preselectedAccountId)
|
||||
?.accountRoot
|
||||
: undefined
|
||||
const autoLoginEnabled = settingsResult.settings.autoLogin
|
||||
// 开发环境允许使用 VITE_DB_KEY;生产安装包只能读取目标电脑自己的 safeStorage。
|
||||
const envKey = getDevelopmentDatabaseKey()
|
||||
// 生产环境以及未配置开发密钥时,读取上一次保存到 safeStorage 的密钥。
|
||||
let savedKey = ''
|
||||
if (!envKey) {
|
||||
const result = await window.api.getSavedDbKey()
|
||||
const result = startupAccountRoot
|
||||
? await window.api.getSavedDbKey(startupAccountRoot)
|
||||
: { success: true, saved: false, encryptionAvailable: true }
|
||||
if (result.success && result.key) savedKey = result.key
|
||||
}
|
||||
const key = envKey || savedKey
|
||||
@@ -570,7 +636,11 @@ function App(): React.ReactElement {
|
||||
try {
|
||||
const startupCacheReady = await loadStartupCache()
|
||||
setIsDatabaseConnecting(true)
|
||||
const initPromise = window.api.initDb(key)
|
||||
if (!startupAccountRoot) {
|
||||
setBootState('login')
|
||||
return
|
||||
}
|
||||
const initPromise = window.api.initDb(key, startupAccountRoot)
|
||||
if (startupCacheReady) {
|
||||
setIsAuthenticated(true)
|
||||
setIsDatabaseConnected(false)
|
||||
@@ -655,13 +725,36 @@ function App(): React.ReactElement {
|
||||
void loadGeneratedReports()
|
||||
}, [isAuthenticated, loadGeneratedReports])
|
||||
|
||||
const handleLogin = async (keyInput?: string): Promise<void> => {
|
||||
const handleLogin = async (keyInput?: string, accountRootInput?: string): Promise<void> => {
|
||||
const keyToUse = keyInput || dbKey
|
||||
if (!keyToUse) return
|
||||
setBootState('connecting')
|
||||
let accountRoot = accountRootInput || selectedAccount?.accountRoot
|
||||
if (!keyToUse || isDatabaseConnecting) return
|
||||
if (!accountRoot) {
|
||||
const discovery = await window.api.discoverAccounts(dbRootInput.trim())
|
||||
if (!discovery.success) {
|
||||
setDbKeyStatus(discovery.error || '微信数据目录不可用')
|
||||
setDbKeyStatusKind('error')
|
||||
return
|
||||
}
|
||||
if (!discovery.preselectedAccountId) {
|
||||
setDiscoveredAccounts(discovery.accounts)
|
||||
setDbKeyStatus('请选择要连接的微信账号')
|
||||
setDbKeyStatusKind('error')
|
||||
return
|
||||
}
|
||||
const account = discovery.accounts.find(
|
||||
(candidate) => candidate.id === discovery.preselectedAccountId
|
||||
)
|
||||
if (!account) return
|
||||
setDiscoveredAccounts(discovery.accounts)
|
||||
setSelectedAccountId(account.id)
|
||||
accountRoot = account.accountRoot
|
||||
}
|
||||
const operationId = ++connectionOperationRef.current
|
||||
if (databaseConnectionMode === 'automatic') setConnectionGuideStep(6)
|
||||
setIsDatabaseConnecting(true)
|
||||
// 持久化用户手动指定的微信聊天文件路径,供 db:init 读取 settings.dbRoot
|
||||
const trimmedRoot = dbRootInput.trim()
|
||||
const trimmedRoot = accountRoot
|
||||
if (trimmedRoot) {
|
||||
try {
|
||||
await window.api.setSettings({ dbRoot: trimmedRoot })
|
||||
@@ -682,7 +775,12 @@ function App(): React.ReactElement {
|
||||
detail: '正在打开 WCDB 数据库',
|
||||
percent: 15
|
||||
})
|
||||
const result = await window.api.initDb(keyToUse)
|
||||
const result = await withTimeout(
|
||||
window.api.initDb(keyToUse, trimmedRoot),
|
||||
DATABASE_CONNECT_TIMEOUT_MS,
|
||||
'数据库连接超时,请检查数据目录后重试'
|
||||
)
|
||||
if (operationId !== connectionOperationRef.current) return
|
||||
const success = typeof result === 'boolean' ? result : result.success
|
||||
if (success) {
|
||||
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
|
||||
@@ -693,8 +791,9 @@ function App(): React.ReactElement {
|
||||
percent: 25
|
||||
})
|
||||
const hasBootstrap = await loadBootstrapCache()
|
||||
if (operationId !== connectionOperationRef.current) return
|
||||
// 持久化手动输入的密钥,供下次启动继续使用
|
||||
void window.api.saveDbKey(keyToUse).catch(() => undefined)
|
||||
void window.api.saveDbKey(trimmedRoot, keyToUse).catch(() => undefined)
|
||||
void window.api.getSettings().then((current) => {
|
||||
if (!current.settings.autoLoginPreferenceSet) {
|
||||
void window.api.setSettings({ autoLogin: true })
|
||||
@@ -734,17 +833,23 @@ function App(): React.ReactElement {
|
||||
}, 500)
|
||||
} else {
|
||||
const error = typeof result === 'boolean' ? '' : result.error
|
||||
setDbKeyStatus(error || '数据库连接失败,请检查密钥和数据目录后重试')
|
||||
setDbKeyStatusKind('error')
|
||||
if (databaseConnectionMode === 'automatic') setConnectionGuideStep(5)
|
||||
setBootState('login')
|
||||
setStartupProgress(null)
|
||||
alert(`Failed to open database.${error ? `\n\n${error}` : '\nCheck your key.'}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
setDbKeyStatus(
|
||||
error instanceof Error ? `数据库连接失败:${error.message}` : '数据库连接失败,请重试'
|
||||
)
|
||||
setDbKeyStatusKind('error')
|
||||
if (databaseConnectionMode === 'automatic') setConnectionGuideStep(5)
|
||||
setBootState('login')
|
||||
setStartupProgress(null)
|
||||
alert('Error connecting to database')
|
||||
} finally {
|
||||
setIsDatabaseConnecting(false)
|
||||
if (operationId === connectionOperationRef.current) setIsDatabaseConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -864,31 +969,42 @@ function App(): React.ReactElement {
|
||||
|
||||
const handleAutoGetDbKey = async (): Promise<void> => {
|
||||
if (isFetchingDbKey) return
|
||||
const operationId = ++connectionOperationRef.current
|
||||
setConnectionGuideStep(4)
|
||||
setIsFetchingDbKey(true)
|
||||
setDbKeyStatus('正在准备获取密钥...')
|
||||
setDbKeyStatusKind('normal')
|
||||
setShowMacKeyFaq(false)
|
||||
try {
|
||||
const result = await window.api.autoGetDbKey()
|
||||
if (!selectedAccount) throw new Error('请先选择微信账号')
|
||||
const result = await window.api.autoGetDbKey(selectedAccount.accountRoot)
|
||||
if (operationId !== connectionOperationRef.current) return
|
||||
if (!result.success || !result.key) {
|
||||
setShowMacKeyFaq(result.code === 'SCAN_FAILED')
|
||||
throw new Error(result.error || '获取密钥失败')
|
||||
}
|
||||
setDbKey(result.key)
|
||||
setDatabaseConnectionMode('manual')
|
||||
setConnectionGuideStep(5)
|
||||
setDbKeyStatus(result.saved ? '密钥已获取并安全保存' : result.warning || '密钥已获取')
|
||||
setDbKeyStatusKind(result.saved ? 'success' : 'normal')
|
||||
} catch (error) {
|
||||
if (operationId !== connectionOperationRef.current) return
|
||||
setDbKeyStatus(error instanceof Error ? error.message : String(error))
|
||||
setDbKeyStatusKind('error')
|
||||
setConnectionGuideStep(3)
|
||||
} finally {
|
||||
setIsFetchingDbKey(false)
|
||||
if (operationId === connectionOperationRef.current) setIsFetchingDbKey(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePasteAndSaveDbKey = async (): Promise<void> => {
|
||||
setShowMacKeyFaq(false)
|
||||
const result = await window.api.pasteAndSaveDbKey()
|
||||
if (!selectedAccount) {
|
||||
setDbKeyStatus('请先选择微信账号')
|
||||
setDbKeyStatusKind('error')
|
||||
return
|
||||
}
|
||||
const result = await window.api.pasteAndSaveDbKey(selectedAccount.accountRoot)
|
||||
if (result.success && result.key) {
|
||||
setDbKey(result.key)
|
||||
setDatabaseConnectionMode('manual')
|
||||
@@ -902,7 +1018,8 @@ function App(): React.ReactElement {
|
||||
|
||||
const handleClearSavedDbKey = async (): Promise<void> => {
|
||||
setShowMacKeyFaq(false)
|
||||
const result = await window.api.clearSavedDbKey()
|
||||
if (!selectedAccount) return
|
||||
const result = await window.api.clearSavedDbKey(selectedAccount.accountRoot)
|
||||
if (!result.success) {
|
||||
setDbKeyStatus(result.error || '清除密钥失败')
|
||||
setDbKeyStatusKind('error')
|
||||
@@ -935,12 +1052,54 @@ function App(): React.ReactElement {
|
||||
setStartupProgress(null)
|
||||
}
|
||||
|
||||
const handleSwitchAccount = async (account: WechatAccountCandidate): Promise<void> => {
|
||||
connectionOperationRef.current += 1
|
||||
await window.api.disconnectDb({ closeNative: true })
|
||||
setIsAuthenticated(false)
|
||||
setIsDatabaseConnected(false)
|
||||
setSelectedContact(null)
|
||||
setMessages([])
|
||||
setContacts([])
|
||||
setFilteredContacts([])
|
||||
setContentFilter('')
|
||||
setSelfInfo(null)
|
||||
setReportSourceContact(null)
|
||||
setExportTasks([])
|
||||
messageHistoryRef.current = []
|
||||
messagesRef.current = []
|
||||
selectedContactMd5Ref.current = ''
|
||||
currentGroupSnapshotRef.current = null
|
||||
groupMemberMetaRef.current = {}
|
||||
syntheticGroupMessagesRef.current = {}
|
||||
setDiscoveredAccounts((current) =>
|
||||
current.some((item) => item.id === account.id) ? current : [account]
|
||||
)
|
||||
setSelectedAccountId(account.id)
|
||||
setDbRootInput(account.accountRoot)
|
||||
await window.api.setSettings({ dbRoot: account.accountRoot, imageKeyRoot: account.accountRoot })
|
||||
const saved = await window.api.getSavedDbKey(account.accountRoot)
|
||||
if (!saved.success || !saved.key) {
|
||||
setDbKey('')
|
||||
setDatabaseConnectionMode('automatic')
|
||||
setBootState('login')
|
||||
setConnectionGuideStep(3)
|
||||
setDbKeyStatus('该账号尚无可用密钥,请为所选账号获取密钥')
|
||||
setDbKeyStatusKind('normal')
|
||||
return
|
||||
}
|
||||
setDbKey(saved.key)
|
||||
setDatabaseConnectionMode('manual')
|
||||
setBootState('login')
|
||||
await handleLogin(saved.key, account.accountRoot)
|
||||
}
|
||||
|
||||
const handleSelectContact = async (contact: Contact, forceLive = false): Promise<void> => {
|
||||
setArchiveJumpTime(null)
|
||||
setSelectedContact(contact)
|
||||
selectedContactMd5Ref.current = contact.md5
|
||||
currentGroupSnapshotRef.current = null
|
||||
setIsMessagesLoading(true)
|
||||
setMessageHistoryStatus('idle')
|
||||
const cachedPage = await window.api.getCachedMessagePage(contact.md5)
|
||||
const cachedMsgs = cachedPage.messages
|
||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||
@@ -1052,7 +1211,7 @@ function App(): React.ReactElement {
|
||||
|
||||
const handleLoadOlderMessages = async (): Promise<void> => {
|
||||
const contact = selectedContact
|
||||
if (!contact || messagesRef.current.length === 0) return
|
||||
if (!contact || messagesRef.current.length === 0 || messageHistoryStatus === 'end') return
|
||||
if (messagePrefetchRef.current) await messagePrefetchRef.current
|
||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||
const currentMessages = messagesRef.current
|
||||
@@ -1089,6 +1248,7 @@ function App(): React.ReactElement {
|
||||
limit: MESSAGE_PAGE_SIZE
|
||||
})
|
||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||
setMessageHistoryStatus(olderMessages.length === 0 ? 'end' : 'idle')
|
||||
messageHistoryRef.current = mergeMessagePages(olderMessages, historyMessages)
|
||||
setMessages((current) =>
|
||||
applyGroupMemberMeta(
|
||||
@@ -1098,6 +1258,7 @@ function App(): React.ReactElement {
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('[Messages] older page load failed:', error)
|
||||
if (selectedContactMd5Ref.current === contact.md5) setMessageHistoryStatus('error')
|
||||
} finally {
|
||||
if (selectedContactMd5Ref.current === contact.md5) setIsMessagesLoading(false)
|
||||
}
|
||||
@@ -1434,6 +1595,7 @@ function App(): React.ReactElement {
|
||||
contact={selectedContact}
|
||||
messages={messages}
|
||||
isLoadingMessages={isMessagesLoading}
|
||||
messageHistoryStatus={messageHistoryStatus}
|
||||
contentFilter={contentFilter}
|
||||
onContentFilterChange={setContentFilter}
|
||||
onRefresh={() => selectedContact && handleSelectContact(selectedContact, true)}
|
||||
@@ -1561,6 +1723,7 @@ function App(): React.ReactElement {
|
||||
onNotice={setReportNotice}
|
||||
onOpenSettings={openSettings}
|
||||
onAppearanceChange={handleAppearanceChange}
|
||||
onSwitchAccount={handleSwitchAccount}
|
||||
/>
|
||||
)
|
||||
case 'search':
|
||||
@@ -1675,15 +1838,80 @@ function App(): React.ReactElement {
|
||||
dbRoot={dbRootInput}
|
||||
showDbKey={showDbKey}
|
||||
isFetching={isFetchingDbKey}
|
||||
isConnecting={isDatabaseConnecting}
|
||||
guideStep={connectionGuideStep}
|
||||
environment={databaseEnvironment}
|
||||
accounts={discoveredAccounts}
|
||||
selectedAccountId={selectedAccountId}
|
||||
status={dbKeyStatus}
|
||||
statusKind={dbKeyStatusKind}
|
||||
showMacKeyFaq={showMacKeyFaq}
|
||||
macKeyFaqUrl={MAC_KEY_FAQ_URL}
|
||||
onModeChange={setDatabaseConnectionMode}
|
||||
onDbKeyChange={setDbKey}
|
||||
onDbRootChange={setDbRootInput}
|
||||
onDbRootChange={(value) => {
|
||||
setDbRootInput(value)
|
||||
setDiscoveredAccounts([])
|
||||
setSelectedAccountId('')
|
||||
}}
|
||||
onSelectAccount={(account) => {
|
||||
setSelectedAccountId(account.id)
|
||||
setDbKey('')
|
||||
void window.api.getSavedDbKey(account.accountRoot).then((result) => {
|
||||
if (result.success && result.key) setDbKey(result.key)
|
||||
})
|
||||
}}
|
||||
onSelectDbRoot={() => {
|
||||
void window.api.selectDbRoot().then((result) => {
|
||||
if (!result.canceled && result.path) {
|
||||
setDbRootInput(result.path)
|
||||
void window.api.discoverAccounts(result.path).then((discovery) => {
|
||||
if (!discovery.success) {
|
||||
setDiscoveredAccounts([])
|
||||
setSelectedAccountId('')
|
||||
setDbKeyStatus(discovery.error || '账号目录识别失败')
|
||||
setDbKeyStatusKind('error')
|
||||
return
|
||||
}
|
||||
setDiscoveredAccounts(discovery.accounts)
|
||||
setSelectedAccountId(discovery.preselectedAccountId || '')
|
||||
})
|
||||
}
|
||||
})
|
||||
}}
|
||||
onToggleDbKey={() => setShowDbKey((visible) => !visible)}
|
||||
onAutoGetKey={handleAutoGetDbKey}
|
||||
onRefreshEnvironment={() => void refreshConnectionEnvironment()}
|
||||
onGuideNext={() =>
|
||||
setConnectionGuideStep((current) => (current === 1 ? 2 : current === 2 ? 3 : current))
|
||||
}
|
||||
onGuideBack={() =>
|
||||
setConnectionGuideStep((current) =>
|
||||
current === 5 ? 3 : current > 1 ? ((current - 1) as 1 | 2 | 3 | 4 | 5 | 6) : 1
|
||||
)
|
||||
}
|
||||
onGuideCancel={() => {
|
||||
connectionOperationRef.current += 1
|
||||
setIsFetchingDbKey(false)
|
||||
setIsDatabaseConnecting(false)
|
||||
setBootState('login')
|
||||
setStartupProgress(null)
|
||||
setConnectionGuideStep(1)
|
||||
setDbKeyStatus('已取消,可以重新检查环境')
|
||||
setDbKeyStatusKind('normal')
|
||||
}}
|
||||
onValidateConnection={() => void handleLogin()}
|
||||
onCopyDiagnostics={() => {
|
||||
if (!databaseEnvironment?.diagnosticSummary) {
|
||||
setDbKeyStatus('诊断信息尚未准备好,请先重新检查环境')
|
||||
setDbKeyStatusKind('error')
|
||||
return
|
||||
}
|
||||
void window.api.copyText(databaseEnvironment.diagnosticSummary).then(() => {
|
||||
setDbKeyStatus('脱敏诊断摘要已复制')
|
||||
setDbKeyStatusKind('success')
|
||||
})
|
||||
}}
|
||||
onManualConnect={() => handleLogin()}
|
||||
onPasteKey={handlePasteAndSaveDbKey}
|
||||
onClearKey={handleClearSavedDbKey}
|
||||
|
||||
@@ -10,6 +10,7 @@ interface ChatWindowProps {
|
||||
contact: Contact | null
|
||||
messages: Message[]
|
||||
isLoadingMessages?: boolean
|
||||
messageHistoryStatus?: 'idle' | 'end' | 'error'
|
||||
contentFilter?: string
|
||||
onContentFilterChange?: (keyword: string) => void
|
||||
onRefresh?: () => void
|
||||
@@ -25,6 +26,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
contact,
|
||||
messages,
|
||||
isLoadingMessages,
|
||||
messageHistoryStatus,
|
||||
contentFilter,
|
||||
onContentFilterChange,
|
||||
onRefresh,
|
||||
@@ -205,6 +207,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
messages={filteredMessages}
|
||||
hiddenMessageCount={0}
|
||||
isLoadingMessages={isLoadingMessages}
|
||||
messageHistoryStatus={messageHistoryStatus}
|
||||
isGroupChat={isGroupChat}
|
||||
showAvatar={showAvatar}
|
||||
listRef={messageListRef}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import type { DatabaseKeyEnvironment, WechatAccountCandidate } from '../../../shared/database-key'
|
||||
|
||||
const GUIDE_URL =
|
||||
'https://github.com/Wxw-Gu/WechatExplorer/blob/main/docs/user-guide/getting-started.md'
|
||||
@@ -13,6 +14,11 @@ interface DatabaseConnectionPageProps {
|
||||
dbRoot: string
|
||||
showDbKey: boolean
|
||||
isFetching: boolean
|
||||
isConnecting: boolean
|
||||
guideStep: 1 | 2 | 3 | 4 | 5 | 6
|
||||
environment?: DatabaseKeyEnvironment
|
||||
accounts: WechatAccountCandidate[]
|
||||
selectedAccountId: string
|
||||
status: string
|
||||
statusKind: DatabaseConnectionStatusKind
|
||||
showMacKeyFaq: boolean
|
||||
@@ -20,8 +26,16 @@ interface DatabaseConnectionPageProps {
|
||||
onModeChange: (mode: DatabaseConnectionMode) => void
|
||||
onDbKeyChange: (value: string) => void
|
||||
onDbRootChange: (value: string) => void
|
||||
onSelectAccount: (account: WechatAccountCandidate) => void
|
||||
onSelectDbRoot: () => void
|
||||
onToggleDbKey: () => void
|
||||
onAutoGetKey: () => void
|
||||
onRefreshEnvironment: () => void
|
||||
onGuideNext: () => void
|
||||
onGuideBack: () => void
|
||||
onGuideCancel: () => void
|
||||
onValidateConnection: () => void
|
||||
onCopyDiagnostics: () => void
|
||||
onManualConnect: () => void
|
||||
onPasteKey: () => void
|
||||
onClearKey: () => void
|
||||
@@ -92,6 +106,11 @@ export function DatabaseConnectionPage({
|
||||
dbRoot,
|
||||
showDbKey,
|
||||
isFetching,
|
||||
isConnecting,
|
||||
guideStep,
|
||||
environment,
|
||||
accounts = [],
|
||||
selectedAccountId = '',
|
||||
status,
|
||||
statusKind,
|
||||
showMacKeyFaq,
|
||||
@@ -99,8 +118,16 @@ export function DatabaseConnectionPage({
|
||||
onModeChange,
|
||||
onDbKeyChange,
|
||||
onDbRootChange,
|
||||
onSelectAccount,
|
||||
onSelectDbRoot,
|
||||
onToggleDbKey,
|
||||
onAutoGetKey,
|
||||
onRefreshEnvironment,
|
||||
onGuideNext,
|
||||
onGuideBack,
|
||||
onGuideCancel,
|
||||
onValidateConnection,
|
||||
onCopyDiagnostics,
|
||||
onManualConnect,
|
||||
onPasteKey,
|
||||
onClearKey
|
||||
@@ -146,27 +173,27 @@ export function DatabaseConnectionPage({
|
||||
<div className="database-login-start">
|
||||
<p className="database-login-eyebrow">第一次使用</p>
|
||||
<h2>开始连接微信</h2>
|
||||
<p>跟着下面 3 步操作,通常几分钟即可完成连接。</p>
|
||||
<p>按顺序检查环境、准备连接组件并验证数据库,通常几分钟即可完成。</p>
|
||||
<ol>
|
||||
<li>
|
||||
<span>1</span>
|
||||
<div>
|
||||
<strong>确认微信数据目录</strong>
|
||||
<small>没有自动找到时,可在设置中手动选择</small>
|
||||
<strong>检查本机环境</strong>
|
||||
<small>确认微信版本、数据目录和运行状态</small>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span>2</span>
|
||||
<div>
|
||||
<strong>让微信停在登录页面</strong>
|
||||
<small>不要在获取密钥前完成登录</small>
|
||||
<strong>准备连接组件</strong>
|
||||
<small>页面会按当前系统给出对应步骤</small>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span>3</span>
|
||||
<div>
|
||||
<strong>点击自动获取密钥</strong>
|
||||
<small>提示可以登录后,再回到微信完成登录</small>
|
||||
<strong>登录并验证连接</strong>
|
||||
<small>验证通过后进入主界面</small>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
@@ -202,6 +229,11 @@ export function DatabaseConnectionPage({
|
||||
|
||||
{mode === 'automatic' ? (
|
||||
<div className="database-login-auto" role="tabpanel">
|
||||
<div className="database-login-guide-progress" aria-label={`连接进度 ${guideStep}/6`}>
|
||||
{Array.from({ length: 6 }, (_, index) => (
|
||||
<span key={index} className={index + 1 <= guideStep ? 'active' : ''} />
|
||||
))}
|
||||
</div>
|
||||
<div className={`database-login-state-card ${statusKind}`}>
|
||||
<div className="database-login-state-heading">
|
||||
<span className="database-login-state-icon">
|
||||
@@ -209,19 +241,46 @@ export function DatabaseConnectionPage({
|
||||
</span>
|
||||
<div>
|
||||
<strong>
|
||||
{statusKind === 'error' ? '未能获取数据库密钥' : '已准备检测微信数据库'}
|
||||
{statusKind === 'error'
|
||||
? '当前步骤未完成'
|
||||
: [
|
||||
'检查本机环境',
|
||||
'让微信停在登录页面',
|
||||
'确认开始准备',
|
||||
`正在完成 ${isMac ? 'macOS' : 'Windows'} 授权`,
|
||||
'现在可以登录微信',
|
||||
'验证数据库连接'
|
||||
][guideStep - 1]}
|
||||
</strong>
|
||||
<p>
|
||||
{statusKind === 'error'
|
||||
? status
|
||||
: status || '请保持微信客户端正在运行,系统将尝试安全获取数据库密钥。'}
|
||||
: status ||
|
||||
[
|
||||
'确认下方检测结果;没有找到目录时可以手动选择。',
|
||||
'请退出当前微信账号,让微信停留在登录页面,然后点击“我已准备好”。',
|
||||
'开始后请按页面提示完成系统授权。',
|
||||
'正在准备连接组件,请不要关闭微信或 WechatExplorer。',
|
||||
'请回到微信完成登录,登录成功后再回来验证。',
|
||||
'正在验证密钥和本地数据库,请稍候。'
|
||||
][guideStep - 1]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{guideStep === 1 && (
|
||||
<>
|
||||
<dl className="database-login-diagnostics">
|
||||
<div>
|
||||
<dt>微信客户端</dt>
|
||||
<dd>{isFetching ? '正在检测' : '等待检测'}</dd>
|
||||
<dt>操作系统</dt>
|
||||
<dd>{environment?.osVersion || (isMac ? 'macOS' : 'Windows')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>微信版本</dt>
|
||||
<dd>{environment?.wechatVersion || '未检测到'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>数据结构</dt>
|
||||
<dd>{environment?.dataStructureVersion || '未检测到'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
@@ -244,22 +303,144 @@ export function DatabaseConnectionPage({
|
||||
{dbRoot || defaultPath}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-path-select"
|
||||
onClick={onSelectDbRoot}
|
||||
disabled={isFetching || isConnecting}
|
||||
>
|
||||
选择目录
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>数据库状态</dt>
|
||||
<dd>{statusKind === 'error' ? '无法连接' : '准备连接'}</dd>
|
||||
<dt>微信状态</dt>
|
||||
<dd>{environment?.wechatRunning ? '运行中' : '未检测到'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{accounts.length > 0 && (
|
||||
<section className="database-account-list" aria-label="选择微信账号">
|
||||
<h3>选择微信账号</h3>
|
||||
{accounts.map((account) => (
|
||||
<button
|
||||
type="button"
|
||||
key={account.id}
|
||||
className={`database-account-card ${selectedAccountId === account.id ? 'selected' : ''}`}
|
||||
aria-pressed={selectedAccountId === account.id}
|
||||
onClick={() => onSelectAccount(account)}
|
||||
>
|
||||
<span className="database-account-avatar">
|
||||
{account.avatar ? (
|
||||
<img src={account.avatar} alt="" />
|
||||
) : (
|
||||
(account.nickname || '?').charAt(0)
|
||||
)}
|
||||
</span>
|
||||
<span className="database-account-identity">
|
||||
<strong>{account.nickname || '昵称未识别'}</strong>
|
||||
<small>{account.wxid || 'wxid 未识别'}</small>
|
||||
<code title={account.accountRoot}>{account.accountRoot}</code>
|
||||
</span>
|
||||
<span className="database-account-status">
|
||||
{account.hasSavedDbKey ? '已有可用密钥' : '尚无可用密钥'}
|
||||
<small>
|
||||
{account.loginStatus === 'current'
|
||||
? '当前已连接账号'
|
||||
: account.loginStatus === 'other'
|
||||
? '非当前账号'
|
||||
: '登录状态未确认'}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-secondary"
|
||||
onClick={onSelectDbRoot}
|
||||
disabled={isFetching || isConnecting}
|
||||
>
|
||||
选择其他账号
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{guideStep === 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-primary"
|
||||
onClick={onAutoGetKey}
|
||||
disabled={isFetching}
|
||||
onClick={onGuideNext}
|
||||
disabled={!selectedAccountId}
|
||||
>
|
||||
{isFetching ? '正在获取密钥…' : statusKind === 'error' ? '重新检测' : '开始获取'}
|
||||
检查完成,继续
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-secondary"
|
||||
onClick={onRefreshEnvironment}
|
||||
>
|
||||
重新检查环境
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-text-action"
|
||||
onClick={onCopyDiagnostics}
|
||||
>
|
||||
复制脱敏诊断摘要
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{guideStep === 2 && (
|
||||
<button type="button" className="database-login-primary" onClick={onGuideNext}>
|
||||
我已准备好
|
||||
</button>
|
||||
)}
|
||||
{guideStep === 3 && (
|
||||
<button type="button" className="database-login-primary" onClick={onAutoGetKey}>
|
||||
开始准备连接组件
|
||||
</button>
|
||||
)}
|
||||
{guideStep === 4 && (
|
||||
<button type="button" className="database-login-primary" disabled>
|
||||
正在准备连接组件…
|
||||
</button>
|
||||
)}
|
||||
{guideStep === 5 && (
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-primary"
|
||||
onClick={onValidateConnection}
|
||||
disabled={!dbKey || isConnecting}
|
||||
>
|
||||
{isConnecting ? '正在验证…' : '微信已登录,验证连接'}
|
||||
</button>
|
||||
)}
|
||||
{guideStep === 6 && (
|
||||
<button type="button" className="database-login-primary" disabled>
|
||||
正在验证数据库…
|
||||
</button>
|
||||
)}
|
||||
{guideStep > 1 && !isFetching && !isConnecting && (
|
||||
<div className="database-login-guide-actions">
|
||||
<button type="button" onClick={onGuideBack}>
|
||||
返回上一步
|
||||
</button>
|
||||
<button type="button" onClick={onGuideCancel}>
|
||||
取消并重新检查
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{(isFetching || isConnecting) && (
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-text-action"
|
||||
onClick={onGuideCancel}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
<p className="database-login-platform-note">
|
||||
{isMac ? (
|
||||
<>
|
||||
@@ -311,8 +492,10 @@ export function DatabaseConnectionPage({
|
||||
微信数据目录
|
||||
<StoragePathHelp />
|
||||
</label>
|
||||
<div className="database-login-root-control">
|
||||
<input
|
||||
id="database-login-root"
|
||||
aria-label="微信数据目录"
|
||||
value={dbRoot}
|
||||
onChange={(event) => onDbRootChange(event.target.value)}
|
||||
placeholder={defaultPath}
|
||||
@@ -320,6 +503,10 @@ export function DatabaseConnectionPage({
|
||||
spellCheck={false}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
/>
|
||||
<button type="button" onClick={onSelectDbRoot} disabled={isConnecting}>
|
||||
选择目录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{status && <div className={`database-login-message ${statusKind}`}>{status}</div>}
|
||||
@@ -327,11 +514,25 @@ export function DatabaseConnectionPage({
|
||||
type="button"
|
||||
className="database-login-primary"
|
||||
onClick={onManualConnect}
|
||||
disabled={!keyIsValid}
|
||||
disabled={!keyIsValid || isConnecting}
|
||||
>
|
||||
连接数据库
|
||||
{isConnecting ? '正在连接…' : '连接数据库'}
|
||||
</button>
|
||||
<button type="button" className="database-login-secondary" onClick={onPasteKey}>
|
||||
{isConnecting && (
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-text-action"
|
||||
onClick={onGuideCancel}
|
||||
>
|
||||
取消连接
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-secondary"
|
||||
onClick={onPasteKey}
|
||||
disabled={isConnecting}
|
||||
>
|
||||
从剪贴板粘贴并安全保存
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ interface MessageListProps {
|
||||
messages: Message[]
|
||||
hiddenMessageCount: number
|
||||
isLoadingMessages?: boolean
|
||||
messageHistoryStatus?: 'idle' | 'end' | 'error'
|
||||
isGroupChat: boolean
|
||||
showAvatar: boolean
|
||||
listRef: React.RefObject<HTMLDivElement | null>
|
||||
@@ -24,6 +25,7 @@ export function MessageList({
|
||||
messages,
|
||||
hiddenMessageCount,
|
||||
isLoadingMessages,
|
||||
messageHistoryStatus,
|
||||
isGroupChat,
|
||||
showAvatar,
|
||||
listRef,
|
||||
@@ -119,6 +121,18 @@ export function MessageList({
|
||||
return (
|
||||
<div className="message-list wechat-message-list" ref={listRef} onScroll={handleScroll}>
|
||||
{isLoadingMessages && <div className="message-loading-pill">正在加载聊天记录...</div>}
|
||||
{messageHistoryStatus === 'end' && (
|
||||
<div className="wechat-system-message-row">
|
||||
<div className="wechat-system-message">已显示本地数据库中的最早记录</div>
|
||||
</div>
|
||||
)}
|
||||
{messageHistoryStatus === 'error' && (
|
||||
<div className="wechat-system-message-row">
|
||||
<div className="wechat-system-message">
|
||||
无法读取更早记录,请检查数据目录或当前微信数据版本
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hiddenMessageCount > 0 && (
|
||||
<div className="wechat-system-message-row">
|
||||
<div className="wechat-system-message">
|
||||
|
||||
@@ -41,7 +41,7 @@ export function ExportWorkspace({
|
||||
const [includeAvatars, setIncludeAvatars] = useState(true)
|
||||
const [preferOriginal, setPreferOriginal] = useState(true)
|
||||
const [fallbackThumbnail, setFallbackThumbnail] = useState(true)
|
||||
const [keepMissing, setKeepMissing] = useState(false)
|
||||
const [keepMissing, setKeepMissing] = useState(true)
|
||||
const [format, setFormat] = useState<ExportFormat>('csv')
|
||||
const [zip, setZip] = useState(false)
|
||||
const [fileName, setFileName] = useState('')
|
||||
@@ -158,6 +158,8 @@ export function ExportWorkspace({
|
||||
|
||||
const handleStart = async (): Promise<void> => {
|
||||
if (!activeContact || status === 'running') return
|
||||
// Runs only from the export button event; a fresh id is required for each job.
|
||||
// eslint-disable-next-line react-hooks/purity
|
||||
const nextJobId = `export-${Date.now()}`
|
||||
setJobId(nextJobId)
|
||||
setProgress(null)
|
||||
@@ -204,6 +206,9 @@ export function ExportWorkspace({
|
||||
: undefined,
|
||||
kinds: Array.from(selectedKinds) as ExportMessageKind[],
|
||||
includeMedia,
|
||||
preferOriginal,
|
||||
fallbackThumbnail,
|
||||
keepMissing,
|
||||
includeAvatars,
|
||||
avatarUrls: exportAvatarUrls,
|
||||
nameMode,
|
||||
@@ -303,20 +308,39 @@ export function ExportWorkspace({
|
||||
<h3>导出格式</h3>
|
||||
<div className="export-format-grid">
|
||||
{formatOrder.map((value) => (
|
||||
<button key={value} type="button" className={format === value ? 'active' : ''} onClick={() => setFormat(value)}>
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={format === value ? 'active' : ''}
|
||||
onClick={() => setFormat(value)}
|
||||
>
|
||||
<strong>{formatLabels[value].label}</strong>
|
||||
{formatLabels[value].hint && <small>{formatLabels[value].hint}</small>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="export-helper-text">CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。</p>
|
||||
<p className="export-helper-text">
|
||||
CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。
|
||||
</p>
|
||||
{format === 'html' && (
|
||||
<div className="export-html-options">
|
||||
<label>
|
||||
<input type="radio" name="html-package-top" checked={!zip} onChange={() => setZip(false)} /> HTML 资源包
|
||||
<input
|
||||
type="radio"
|
||||
name="html-package-top"
|
||||
checked={!zip}
|
||||
onChange={() => setZip(false)}
|
||||
/>{' '}
|
||||
HTML 资源包
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="html-package-top" checked={zip} onChange={() => setZip(true)} /> HTML 资源包并压缩为 ZIP
|
||||
<input
|
||||
type="radio"
|
||||
name="html-package-top"
|
||||
checked={zip}
|
||||
onChange={() => setZip(true)}
|
||||
/>{' '}
|
||||
HTML 资源包并压缩为 ZIP
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
@@ -383,19 +407,13 @@ export function ExportWorkspace({
|
||||
<h3>消息内容</h3>
|
||||
<div className="export-kind-grid">
|
||||
{messageKinds.map(([value, label]) => (
|
||||
<label key={value} className={`export-check-row ${value === 'video' ? 'unsupported' : ''}`}>
|
||||
<label key={value} className="export-check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value !== 'video' && selectedKinds.has(value)}
|
||||
disabled={value === 'video'}
|
||||
checked={selectedKinds.has(value)}
|
||||
onChange={() => toggleKind(value)}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
{value === 'video' && (
|
||||
<span className="export-unsupported-hint" title="当前版本暂不支持视频导出" aria-label="当前版本暂不支持视频导出">
|
||||
!
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@@ -429,7 +447,9 @@ export function ExportWorkspace({
|
||||
onChange={(event) => setIncludeMedia(event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
<div className={`export-media-options ${includeMedia && format === 'html' ? '' : 'disabled'}`}>
|
||||
<div
|
||||
className={`export-media-options ${includeMedia && format === 'html' ? '' : 'disabled'}`}
|
||||
>
|
||||
<label className="export-check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -458,7 +478,9 @@ export function ExportWorkspace({
|
||||
<span>媒体缺失时保留占位说明</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="export-helper-text">资源文件仅在 HTML 导出中生效,CSV、JSON 和 Markdown 只保留文本内容。</p>
|
||||
<p className="export-helper-text">
|
||||
资源文件仅在 HTML 导出中生效,CSV、JSON 和 Markdown 只保留文本内容。
|
||||
</p>
|
||||
<div className="export-resource-statuses">
|
||||
<span>图片解密:已就绪</span>
|
||||
<span>视频资源:可用</span>
|
||||
|
||||
@@ -30,7 +30,8 @@ export function SettingsWorkspace({
|
||||
onAIRuntimeChange,
|
||||
onNotice,
|
||||
onOpenSettings,
|
||||
onAppearanceChange
|
||||
onAppearanceChange,
|
||||
onSwitchAccount
|
||||
}: {
|
||||
selectedCategory: SettingsCategoryId
|
||||
onCategoryChange: (id: SettingsCategoryId) => void
|
||||
@@ -47,7 +48,13 @@ export function SettingsWorkspace({
|
||||
onAIRuntimeChange: (config: AIRuntimeModelConfig) => void
|
||||
onNotice: (message: string) => void
|
||||
onOpenSettings: () => void
|
||||
onAppearanceChange: (settings: { theme: 'system' | 'light' | 'dark'; compactMode: boolean }) => void
|
||||
onAppearanceChange: (settings: {
|
||||
theme: 'system' | 'light' | 'dark'
|
||||
compactMode: boolean
|
||||
}) => void
|
||||
onSwitchAccount: (
|
||||
account: import('../../../../shared/database-key').WechatAccountCandidate
|
||||
) => Promise<void>
|
||||
}): React.ReactElement {
|
||||
const renderSelectedPage = (): React.ReactElement => {
|
||||
switch (selectedCategory) {
|
||||
@@ -59,6 +66,7 @@ export function SettingsWorkspace({
|
||||
dbConnecting={dbConnecting}
|
||||
selfInfo={selfInfo}
|
||||
onNotice={onNotice}
|
||||
onSwitchAccount={onSwitchAccount}
|
||||
/>
|
||||
)
|
||||
case 'database-key':
|
||||
@@ -87,9 +95,7 @@ export function SettingsWorkspace({
|
||||
case 'cache-cleanup':
|
||||
return <CacheCleanupPage onNotice={onNotice} />
|
||||
case 'appearance':
|
||||
return (
|
||||
<AppearancePage onNotice={onNotice} onAppearanceChange={onAppearanceChange} />
|
||||
)
|
||||
return <AppearancePage onNotice={onNotice} onAppearanceChange={onAppearanceChange} />
|
||||
case 'about':
|
||||
return <AboutPage onNotice={onNotice} />
|
||||
default:
|
||||
|
||||
@@ -25,7 +25,8 @@ export function AccountOverview({
|
||||
isChecking,
|
||||
onCheck,
|
||||
onOpenDirectory,
|
||||
onCopyDirectory
|
||||
onCopyDirectory,
|
||||
onSwitchAccount
|
||||
}: {
|
||||
selfInfo: SettingsSelfInfo | null
|
||||
connectionStatus: ConnectionOverviewStatus
|
||||
@@ -34,6 +35,7 @@ export function AccountOverview({
|
||||
onCheck: () => void
|
||||
onOpenDirectory: () => void
|
||||
onCopyDirectory: () => void
|
||||
onSwitchAccount: () => void
|
||||
}): React.ReactElement {
|
||||
const accountRoot = selfInfo?.accountRoot || ''
|
||||
return (
|
||||
@@ -83,6 +85,9 @@ export function AccountOverview({
|
||||
>
|
||||
打开账号目录
|
||||
</button>
|
||||
<button type="button" className="api-secondary-button" onClick={onSwitchAccount}>
|
||||
切换账号
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-account-root">
|
||||
|
||||
@@ -36,14 +36,14 @@ export function useDatabaseKeyController({
|
||||
}, [])
|
||||
|
||||
const refreshStorage = useCallback(async (): Promise<void> => {
|
||||
const result = await window.api.getSavedDbKey()
|
||||
const result = await window.api.getSavedDbKey(selfInfo?.accountRoot || '')
|
||||
dispatch({
|
||||
type: 'STORAGE_LOADED',
|
||||
saved: result.saved,
|
||||
encryptionAvailable: result.encryptionAvailable,
|
||||
error: result.success ? undefined : result.error
|
||||
})
|
||||
}, [])
|
||||
}, [selfInfo?.accountRoot])
|
||||
|
||||
useEffect(() => {
|
||||
void Promise.all([refreshStorage(), refreshEnvironment()])
|
||||
@@ -109,7 +109,8 @@ export function useDatabaseKeyController({
|
||||
const saveKey = useCallback(async (): Promise<void> => {
|
||||
if (state.status !== 'valid') return
|
||||
dispatch({ type: 'SAVE_START' })
|
||||
const saved = await window.api.saveDbKey(dbKey)
|
||||
const accountRoot = selfInfo?.accountRoot || ''
|
||||
const saved = await window.api.saveDbKey(accountRoot, dbKey)
|
||||
if (!saved.success || !saved.key) {
|
||||
dispatch({
|
||||
type: 'SAVE_ERROR',
|
||||
@@ -117,12 +118,12 @@ export function useDatabaseKeyController({
|
||||
})
|
||||
return
|
||||
}
|
||||
const stored = await window.api.getSavedDbKey()
|
||||
const stored = await window.api.getSavedDbKey(accountRoot)
|
||||
if (!stored.success || !stored.saved || !stored.key) {
|
||||
dispatch({ type: 'SAVE_ERROR', error: '无法确认密钥保存状态' })
|
||||
return
|
||||
}
|
||||
const initialized = await window.api.initDb(stored.key)
|
||||
const initialized = await window.api.initDb(stored.key, accountRoot)
|
||||
const connected = typeof initialized === 'boolean' ? initialized : initialized.success
|
||||
onDbKeyChange(stored.key)
|
||||
onDatabaseConnectionChange(connected)
|
||||
@@ -148,13 +149,14 @@ export function useDatabaseKeyController({
|
||||
onNotice,
|
||||
onSelfInfoChange,
|
||||
refreshEnvironment,
|
||||
selfInfo?.accountRoot,
|
||||
state.status
|
||||
])
|
||||
|
||||
const autoDetectKey = useCallback(async (): Promise<void> => {
|
||||
dispatch({ type: 'AUTO_START' })
|
||||
await refreshEnvironment()
|
||||
const result = await window.api.autoGetDbKey({ save: false })
|
||||
const result = await window.api.autoGetDbKey(selfInfo?.accountRoot || '', { save: false })
|
||||
if (!result.success || !result.key) {
|
||||
dispatch({ type: 'AUTO_ERROR', error: result.error || '暂未找到有效密钥' })
|
||||
return
|
||||
@@ -162,11 +164,11 @@ export function useDatabaseKeyController({
|
||||
onDbKeyChange(result.key)
|
||||
dispatch({ type: 'AUTO_SUCCESS' })
|
||||
await runValidation(result.key)
|
||||
}, [onDbKeyChange, refreshEnvironment, runValidation])
|
||||
}, [onDbKeyChange, refreshEnvironment, runValidation, selfInfo?.accountRoot])
|
||||
|
||||
const clearSavedKey = useCallback(async (): Promise<void> => {
|
||||
dispatch({ type: 'CLEAR_START' })
|
||||
const result = await window.api.clearSavedDbKey()
|
||||
const result = await window.api.clearSavedDbKey(selfInfo?.accountRoot || '')
|
||||
if (!result.success) {
|
||||
dispatch({ type: 'CLEAR_ERROR', error: '清除密钥失败' })
|
||||
return
|
||||
@@ -187,7 +189,8 @@ export function useDatabaseKeyController({
|
||||
onFilteredContactsChange,
|
||||
onNotice,
|
||||
onSelfInfoChange,
|
||||
refreshEnvironment
|
||||
refreshEnvironment,
|
||||
selfInfo?.accountRoot
|
||||
])
|
||||
|
||||
const returnToLogin = useCallback(async (): Promise<void> => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { LocalPrivacyNotice } from '../account-database/LocalPrivacyNotice'
|
||||
import { useAccountDatabaseController } from '../account-database/useAccountDatabaseController'
|
||||
import type { ConnectionOverviewStatus } from '../account-database/types'
|
||||
import type { SettingsSelfInfo } from '../model/types'
|
||||
import type { WechatAccountCandidate } from '../../../../../shared/database-key'
|
||||
|
||||
const STATUS_LABELS: Record<ConnectionOverviewStatus, string> = {
|
||||
checking: '正在检测',
|
||||
@@ -19,13 +20,15 @@ export function AccountDatabasePage({
|
||||
dbReady,
|
||||
dbConnecting = false,
|
||||
selfInfo,
|
||||
onNotice
|
||||
onNotice,
|
||||
onSwitchAccount
|
||||
}: {
|
||||
dbKey: string
|
||||
dbReady: boolean
|
||||
dbConnecting?: boolean
|
||||
selfInfo: SettingsSelfInfo | null
|
||||
onNotice: (message: string) => void
|
||||
onSwitchAccount: (account: WechatAccountCandidate) => Promise<void>
|
||||
}): React.ReactElement {
|
||||
const controller = useAccountDatabaseController({
|
||||
dbKey,
|
||||
@@ -35,6 +38,8 @@ export function AccountDatabasePage({
|
||||
onNotice
|
||||
})
|
||||
const [autoLogin, setAutoLogin] = useState(false)
|
||||
const [switching, setSwitching] = useState(false)
|
||||
const [accounts, setAccounts] = useState<WechatAccountCandidate[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
@@ -56,6 +61,18 @@ export function AccountDatabasePage({
|
||||
onNotice(checked ? '已开启启动时自动连接' : '已关闭启动时自动连接')
|
||||
}
|
||||
|
||||
const openAccountSwitcher = async (): Promise<void> => {
|
||||
if (!selfInfo?.accountRoot) return
|
||||
const parentRoot = selfInfo.accountRoot.replace(/[\\/][^\\/]+[\\/]?$/, '')
|
||||
const result = await window.api.discoverAccounts(parentRoot)
|
||||
if (!result.success) {
|
||||
onNotice(result.error || '无法读取账号列表')
|
||||
return
|
||||
}
|
||||
setAccounts(result.accounts)
|
||||
setSwitching(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-page">
|
||||
<header className="settings-page-header">
|
||||
@@ -79,7 +96,45 @@ export function AccountDatabasePage({
|
||||
onCheck={() => void controller.testConnection()}
|
||||
onOpenDirectory={() => void controller.openAccountDirectory()}
|
||||
onCopyDirectory={() => void controller.copyAccountDirectory()}
|
||||
onSwitchAccount={() => void openAccountSwitcher()}
|
||||
/>
|
||||
{switching && (
|
||||
<section className="settings-card database-account-list" aria-label="切换微信账号">
|
||||
<h2>选择要切换的账号</h2>
|
||||
{accounts.map((account) => (
|
||||
<button
|
||||
type="button"
|
||||
key={account.id}
|
||||
className="database-account-card"
|
||||
disabled={account.accountRoot === selfInfo?.accountRoot}
|
||||
onClick={() => void onSwitchAccount(account).then(() => setSwitching(false))}
|
||||
>
|
||||
<span className="database-account-avatar">
|
||||
{account.avatar ? (
|
||||
<img src={account.avatar} alt="" />
|
||||
) : (
|
||||
(account.nickname || '?').charAt(0)
|
||||
)}
|
||||
</span>
|
||||
<span className="database-account-identity">
|
||||
<strong>{account.nickname || '昵称未识别'}</strong>
|
||||
<small>{account.wxid || 'wxid 未识别'}</small>
|
||||
<code>{account.accountRoot}</code>
|
||||
</span>
|
||||
<span className="database-account-status">
|
||||
{account.hasSavedDbKey ? '已有可用密钥' : '需要获取密钥'}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="api-secondary-button"
|
||||
onClick={() => setSwitching(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
<h2 className="settings-section-heading">连接健康检查</h2>
|
||||
<ConnectionHealthSection
|
||||
diagnostics={controller.diagnostics}
|
||||
|
||||
@@ -1185,7 +1185,7 @@
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 48px clamp(40px, 7vw, 96px);
|
||||
padding: 28px clamp(40px, 7vw, 96px);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@@ -1195,7 +1195,7 @@
|
||||
}
|
||||
|
||||
.database-login-start {
|
||||
margin-bottom: 22px;
|
||||
margin-bottom: 14px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
@@ -1285,7 +1285,7 @@
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
margin-bottom: 28px;
|
||||
margin-bottom: 18px;
|
||||
border: 1px solid var(--login-border);
|
||||
border-radius: 8px;
|
||||
background: #e9eeeb;
|
||||
@@ -1410,6 +1410,58 @@
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.database-login-guide-progress {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 5px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.database-login-guide-progress span {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: #dce4df;
|
||||
}
|
||||
|
||||
.database-login-guide-progress span.active {
|
||||
background: var(--login-primary);
|
||||
}
|
||||
|
||||
.database-login-guide-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.database-login-guide-actions button,
|
||||
.database-login-text-action {
|
||||
padding: 5px 0;
|
||||
border: 0;
|
||||
color: var(--login-primary-dark);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.database-login-text-action {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.database-login-path-select {
|
||||
margin-top: 5px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--login-border);
|
||||
border-radius: 5px;
|
||||
color: var(--login-primary-dark);
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.database-login-path-input-wrap {
|
||||
position: relative;
|
||||
display: block;
|
||||
@@ -1596,6 +1648,7 @@
|
||||
}
|
||||
|
||||
.database-login-field > input,
|
||||
.database-login-root-control,
|
||||
.database-login-key-input {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
@@ -1604,6 +1657,37 @@
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.database-login-root-control {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.database-login-root-control input {
|
||||
min-width: 0;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: var(--login-text);
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.database-login-root-control button {
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-left: 1px solid var(--login-border);
|
||||
color: var(--login-primary-dark);
|
||||
background: var(--login-surface-low);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.database-login-root-control button:disabled,
|
||||
.database-login-path-select:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.database-login-field > input {
|
||||
padding: 0 12px;
|
||||
color: var(--login-text);
|
||||
@@ -1617,6 +1701,7 @@
|
||||
}
|
||||
|
||||
.database-login-key-input:focus-within,
|
||||
.database-login-root-control:focus-within,
|
||||
.database-login-field > input:focus {
|
||||
border-color: var(--login-primary);
|
||||
box-shadow: 0 0 0 3px rgba(36, 122, 99, 0.1);
|
||||
@@ -1969,3 +2054,70 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
.database-account-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 14px 0;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.database-account-card {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-color, #d8e2dc);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&.selected {
|
||||
border-color: #176b57;
|
||||
box-shadow: 0 0 0 2px #176b5720;
|
||||
}
|
||||
}
|
||||
|
||||
.database-account-avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
background: #dcebe4;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.database-account-identity,
|
||||
.database-account-status {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
|
||||
small,
|
||||
code {
|
||||
color: var(--text-secondary, #68766f);
|
||||
}
|
||||
code {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.database-account-status {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { DatabaseKeyEnvironment } from './database-key'
|
||||
|
||||
export function buildSafeDiagnosticSummary(
|
||||
environment: Omit<DatabaseKeyEnvironment, 'diagnosticSummary'>
|
||||
): string {
|
||||
return [
|
||||
`WechatExplorer: ${environment.appVersion}`,
|
||||
`操作系统: ${environment.osVersion}`,
|
||||
`微信客户端: ${environment.wechatVersion}`,
|
||||
`数据结构: ${environment.dataStructureVersion}`,
|
||||
`数据目录: ${environment.dataDirectoryDetected ? '已检测到' : '未检测到'}`,
|
||||
`微信进程: ${environment.wechatRunning ? '运行中' : '未运行'}`,
|
||||
`数据库连接: ${environment.dbConnected ? '已连接' : '未连接'}`,
|
||||
`安全存储: ${environment.encryptionAvailable ? '可用' : '不可用'}`
|
||||
].join('\n')
|
||||
}
|
||||
@@ -24,8 +24,36 @@ export interface DatabaseKeyStorageResult {
|
||||
encryptionAvailable: boolean
|
||||
}
|
||||
|
||||
export type AccountLoginStatus = 'current' | 'other' | 'unknown'
|
||||
|
||||
export interface WechatAccountCandidate {
|
||||
id: string
|
||||
accountRoot: string
|
||||
directoryName: string
|
||||
wxid?: string
|
||||
nickname?: string
|
||||
avatar?: string
|
||||
hasSavedDbKey: boolean
|
||||
loginStatus: AccountLoginStatus
|
||||
selectedByInput: boolean
|
||||
}
|
||||
|
||||
export interface AccountDiscoveryResult {
|
||||
success: boolean
|
||||
inputKind?: 'root' | 'account'
|
||||
accounts: WechatAccountCandidate[]
|
||||
preselectedAccountId?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface DatabaseKeyEnvironment {
|
||||
platform: NodeJS.Platform
|
||||
osVersion: string
|
||||
appVersion: string
|
||||
wechatVersion: string
|
||||
dataStructureVersion: string
|
||||
dataDirectoryDetected: boolean
|
||||
diagnosticSummary: string
|
||||
autoDetectSupported: boolean
|
||||
wechatRunning: boolean
|
||||
accountIdentified: boolean
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ExportRequest } from './export'
|
||||
|
||||
export type ImageExportAttempt = {
|
||||
allowThumbnail: boolean
|
||||
preferThumbnail: boolean
|
||||
fallback: boolean
|
||||
}
|
||||
|
||||
export function getImageExportAttempts(
|
||||
request: Pick<ExportRequest, 'preferOriginal' | 'fallbackThumbnail'>
|
||||
): ImageExportAttempt[] {
|
||||
if (request.preferOriginal === false) {
|
||||
return [{ allowThumbnail: true, preferThumbnail: true, fallback: false }]
|
||||
}
|
||||
const attempts: ImageExportAttempt[] = [
|
||||
{ allowThumbnail: false, preferThumbnail: false, fallback: false }
|
||||
]
|
||||
if (request.fallbackThumbnail !== false) {
|
||||
attempts.push({ allowThumbnail: true, preferThumbnail: true, fallback: true })
|
||||
}
|
||||
return attempts
|
||||
}
|
||||
@@ -23,6 +23,9 @@ export interface ExportRequest {
|
||||
endTime?: number
|
||||
kinds: ExportMessageKind[]
|
||||
includeMedia: boolean
|
||||
preferOriginal?: boolean
|
||||
fallbackThumbnail?: boolean
|
||||
keepMissing?: boolean
|
||||
includeAvatars?: boolean
|
||||
avatarUrls?: Record<string, string>
|
||||
nameMode?: ExportNameMode
|
||||
|
||||
@@ -14,6 +14,35 @@ function renderPage(
|
||||
dbRoot: '',
|
||||
showDbKey: false,
|
||||
isFetching: false,
|
||||
isConnecting: false,
|
||||
guideStep: 1 as const,
|
||||
environment: {
|
||||
platform: 'win32',
|
||||
osVersion: 'Windows fixture',
|
||||
appVersion: 'v2.1.6',
|
||||
wechatVersion: '4.1.9.57',
|
||||
dataStructureVersion: '微信 4.x(WCDB)',
|
||||
dataDirectoryDetected: true,
|
||||
diagnosticSummary: 'WechatExplorer: v2.1.6',
|
||||
autoDetectSupported: true,
|
||||
wechatRunning: true,
|
||||
accountIdentified: false,
|
||||
dbConnected: false,
|
||||
encryptionAvailable: true
|
||||
},
|
||||
accounts: [
|
||||
{
|
||||
id: 'account-a',
|
||||
accountRoot: 'C:\\fixture\\account-a',
|
||||
directoryName: 'account-a',
|
||||
nickname: '脱敏账号 A',
|
||||
wxid: 'wxid_fixture_a',
|
||||
hasSavedDbKey: true,
|
||||
loginStatus: 'unknown' as const,
|
||||
selectedByInput: true
|
||||
}
|
||||
],
|
||||
selectedAccountId: 'account-a',
|
||||
status: '',
|
||||
statusKind: 'normal' as const,
|
||||
showMacKeyFaq: false,
|
||||
@@ -21,8 +50,16 @@ function renderPage(
|
||||
onModeChange: vi.fn(),
|
||||
onDbKeyChange: vi.fn(),
|
||||
onDbRootChange: vi.fn(),
|
||||
onSelectAccount: vi.fn(),
|
||||
onSelectDbRoot: vi.fn(),
|
||||
onToggleDbKey: vi.fn(),
|
||||
onAutoGetKey: vi.fn(),
|
||||
onRefreshEnvironment: vi.fn(),
|
||||
onGuideNext: vi.fn(),
|
||||
onGuideBack: vi.fn(),
|
||||
onGuideCancel: vi.fn(),
|
||||
onValidateConnection: vi.fn(),
|
||||
onCopyDiagnostics: vi.fn(),
|
||||
onManualConnect: vi.fn(),
|
||||
onPasteKey: vi.fn(),
|
||||
onClearKey: vi.fn(),
|
||||
@@ -52,4 +89,48 @@ describe('DatabaseConnectionPage', () => {
|
||||
expect(onManualConnect).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('button', { name: '从剪贴板粘贴并安全保存' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('restores directory editing and selection after a failed connection', async () => {
|
||||
const onDbRootChange = vi.fn()
|
||||
const onSelectDbRoot = vi.fn()
|
||||
renderPage({
|
||||
dbKey: 'b'.repeat(64),
|
||||
dbRoot: 'Z:\\missing-wechat-data',
|
||||
status: '微信数据目录不存在,请重新选择目录',
|
||||
statusKind: 'error',
|
||||
onDbRootChange,
|
||||
onSelectDbRoot
|
||||
})
|
||||
|
||||
await userEvent.clear(screen.getByLabelText('微信数据目录'))
|
||||
await userEvent.type(screen.getByLabelText('微信数据目录'), 'C:\\fixture-account')
|
||||
await userEvent.click(screen.getByRole('button', { name: '选择目录' }))
|
||||
|
||||
expect(onDbRootChange).toHaveBeenCalled()
|
||||
expect(onSelectDbRoot).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('button', { name: '连接数据库' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('supports forward, back, cancel and safe diagnostic actions in onboarding', async () => {
|
||||
const onGuideNext = vi.fn()
|
||||
const onCopyDiagnostics = vi.fn()
|
||||
const { rerender, props } = renderPage({
|
||||
mode: 'automatic',
|
||||
guideStep: 1,
|
||||
onGuideNext,
|
||||
onCopyDiagnostics
|
||||
})
|
||||
|
||||
expect(screen.getByText('4.1.9.57')).toBeVisible()
|
||||
expect(screen.getByText('微信 4.x(WCDB)')).toBeVisible()
|
||||
await userEvent.click(screen.getByRole('button', { name: '复制脱敏诊断摘要' }))
|
||||
await userEvent.click(screen.getByRole('button', { name: '检查完成,继续' }))
|
||||
expect(onCopyDiagnostics).toHaveBeenCalledOnce()
|
||||
expect(onGuideNext).toHaveBeenCalledOnce()
|
||||
|
||||
rerender(<DatabaseConnectionPage {...props} mode="automatic" guideStep={2} />)
|
||||
expect(screen.getByRole('button', { name: '我已准备好' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: '返回上一步' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: '取消并重新检查' })).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 81 KiB |
+44
-7
@@ -23,13 +23,8 @@ test('KEY-01 KEY-02 invalid key remains recoverable and valid key enters the app
|
||||
await fixture.page.getByRole('tab', { name: /高级用户/ }).click()
|
||||
const keyInput = fixture.page.getByLabel('数据库密钥')
|
||||
await keyInput.fill('b'.repeat(64))
|
||||
const errorDialog = fixture.page.waitForEvent('dialog')
|
||||
await fixture.page
|
||||
.getByRole('button', { name: '连接数据库' })
|
||||
.evaluate((element: HTMLButtonElement) => element.click())
|
||||
const dialog = await errorDialog
|
||||
expect(dialog.message()).toContain('数据库密钥无效')
|
||||
await dialog.dismiss()
|
||||
await fixture.page.getByRole('button', { name: '连接数据库' }).click()
|
||||
await expect(fixture.page.getByText('数据库密钥无效')).toBeVisible()
|
||||
|
||||
await expect(keyInput).toBeVisible()
|
||||
await keyInput.fill('a'.repeat(64))
|
||||
@@ -40,6 +35,46 @@ test('KEY-01 KEY-02 invalid key remains recoverable and valid key enters the app
|
||||
}
|
||||
})
|
||||
|
||||
test('P0-01 an invalid directory can be corrected and retried without restarting', async () => {
|
||||
test.skip(process.platform !== 'win32', 'Manual database directory editing is Windows-only')
|
||||
const fixture = await launchTestApp({ mode: 'disconnected' })
|
||||
try {
|
||||
await fixture.page.getByRole('tab', { name: /高级用户/ }).click()
|
||||
await fixture.page.getByLabel('数据库密钥').fill('a'.repeat(64))
|
||||
await fixture.page.getByLabel('微信数据目录').fill('Z:\\missing-wechat-data')
|
||||
await fixture.page.getByRole('button', { name: '连接数据库' }).click()
|
||||
|
||||
await expect(fixture.page.getByText('微信数据目录不存在,请重新选择目录')).toBeVisible()
|
||||
await expect(fixture.page.getByLabel('微信数据目录')).toBeEditable()
|
||||
await expect(fixture.page.getByRole('button', { name: '选择目录' })).toBeEnabled()
|
||||
|
||||
await fixture.page.getByRole('button', { name: '选择目录' }).click()
|
||||
await expect(fixture.page.getByLabel('微信数据目录')).toHaveValue('fixture-account')
|
||||
await fixture.page.getByRole('button', { name: '连接数据库' }).click()
|
||||
await expect(fixture.page.getByRole('navigation', { name: '一级导航' })).toBeVisible()
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('P2-01 P2-02 guided connection exposes safe diagnostics and completes all stages', async () => {
|
||||
const fixture = await launchTestApp({ mode: 'disconnected' })
|
||||
try {
|
||||
await expect(fixture.page.getByText('4.1.9.57')).toBeVisible()
|
||||
await expect(fixture.page.getByText('微信 4.x(WCDB)')).toBeVisible()
|
||||
await expect(fixture.page.getByRole('button', { name: '复制脱敏诊断摘要' })).toBeEnabled()
|
||||
|
||||
await fixture.page.getByRole('button', { name: '检查完成,继续' }).click()
|
||||
await fixture.page.getByRole('button', { name: '我已准备好' }).click()
|
||||
await fixture.page.getByRole('button', { name: '开始准备连接组件' }).click()
|
||||
await expect(fixture.page.getByRole('button', { name: '微信已登录,验证连接' })).toBeEnabled()
|
||||
await fixture.page.getByRole('button', { name: '微信已登录,验证连接' }).click()
|
||||
await expect(fixture.page.getByRole('navigation', { name: '一级导航' })).toBeVisible()
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('KEY-03 changing one key does not invalidate archive data or unrelated settings', async () => {
|
||||
const fixture = await launchTestApp()
|
||||
try {
|
||||
@@ -188,6 +223,7 @@ test('REPORT-01 REPORT-02 generates a fixed report with non-empty local assets',
|
||||
await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
|
||||
await expect(fixture.page.getByRole('heading', { name: '生成群聊日报' })).toBeVisible()
|
||||
await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).click()
|
||||
await fixture.page.getByRole('button', { name: '近 7 天' }).click()
|
||||
const generate = fixture.page.getByRole('button', { name: '开始生成日报' })
|
||||
await expect(generate).toBeEnabled()
|
||||
await generate.click()
|
||||
@@ -218,6 +254,7 @@ test('REPORT-03 report failure is retryable and leaves other pages usable', asyn
|
||||
await fixture.page.getByRole('button', { name: '日报' }).click()
|
||||
await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
|
||||
await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).click()
|
||||
await fixture.page.getByRole('button', { name: '近 7 天' }).click()
|
||||
await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
|
||||
await expect(fixture.page.getByText(/本地假服务错误 401/).first()).toBeVisible()
|
||||
await expect(fixture.page.getByRole('button', { name: '重试' })).toBeEnabled()
|
||||
|
||||
@@ -102,7 +102,7 @@ handle('key:getSavedDbKey', () => ({
|
||||
saved: Boolean(savedKey),
|
||||
encryptionAvailable: true
|
||||
}))
|
||||
handle('key:saveDbKey', (key) => {
|
||||
handle('key:saveDbKey', (_accountRoot, key) => {
|
||||
savedKey = String(key || '')
|
||||
return { success: true, key: savedKey, saved: true, encryptionAvailable: true }
|
||||
})
|
||||
@@ -112,6 +112,12 @@ handle('key:clearSavedDbKey', () => {
|
||||
})
|
||||
handle('key:getEnvironment', () => ({
|
||||
platform: process.platform,
|
||||
osVersion: process.platform === 'win32' ? 'Windows fixture' : 'macOS fixture',
|
||||
appVersion: 'v2.1.6',
|
||||
wechatVersion: '4.1.9.57',
|
||||
dataStructureVersion: settings.dbRoot === 'fixture-account' ? '微信 4.x(WCDB)' : '未检测到',
|
||||
dataDirectoryDetected: settings.dbRoot === 'fixture-account',
|
||||
diagnosticSummary: 'WechatExplorer: v2.1.6\n数据目录: 已检测到',
|
||||
autoDetectSupported: true,
|
||||
wechatRunning: true,
|
||||
accountIdentified: connected,
|
||||
@@ -128,12 +134,22 @@ handle('key:autoGetImageKey', () => ({
|
||||
verified: true
|
||||
}))
|
||||
|
||||
handle('db:init', (key) => {
|
||||
handle('db:init', (key, accountRoot) => {
|
||||
if (settings.dbRoot === 'Z:\\missing-wechat-data') {
|
||||
connected = false
|
||||
return {
|
||||
success: false,
|
||||
code: 'ROOT_UNAVAILABLE',
|
||||
error: '微信数据目录不存在,请重新选择目录',
|
||||
monitoring: false
|
||||
}
|
||||
}
|
||||
if (key !== VALID_KEY) {
|
||||
connected = false
|
||||
return { success: false, error: '数据库密钥无效', monitoring: false }
|
||||
}
|
||||
connected = true
|
||||
settings.dbRoot = accountRoot || settings.dbRoot
|
||||
return { success: true, monitoring: true }
|
||||
})
|
||||
handle('db:testConnection', (key) =>
|
||||
@@ -339,6 +355,29 @@ handle('image:getStatus', () => ({
|
||||
])
|
||||
)
|
||||
}))
|
||||
handle('settings:selectDbRoot', () => ({ canceled: false, path: 'fixture-account' }))
|
||||
handle('accounts:discover', (inputPath) =>
|
||||
inputPath === 'Z:\\missing-wechat-data'
|
||||
? { success: false, accounts: [], error: '微信数据目录不存在,请重新选择目录' }
|
||||
: {
|
||||
success: true,
|
||||
inputKind: 'account',
|
||||
preselectedAccountId: 'fixture-account-id',
|
||||
accounts: [
|
||||
{
|
||||
id: 'fixture-account-id',
|
||||
accountRoot: inputPath || 'fixture-account',
|
||||
directoryName: 'fixture-account',
|
||||
wxid: fixture.self.wxid,
|
||||
nickname: fixture.self.nickname,
|
||||
avatar: fixture.self.avatar,
|
||||
hasSavedDbKey: Boolean(savedKey),
|
||||
loginStatus: connected ? 'current' : 'unknown',
|
||||
selectedByInput: true
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
handle('agent-hub:getStatus', () => ({ state: 'disconnected', connected: false }))
|
||||
handle('agent-hub:getLogs', () => [])
|
||||
handle('app-update:getState', () => ({ status: 'idle', currentVersion: '2.1.6' }))
|
||||
@@ -347,7 +386,6 @@ for (const channel of [
|
||||
'export:start',
|
||||
'export:cancel',
|
||||
'export:reveal',
|
||||
'settings:selectDbRoot',
|
||||
'settings:openAccountRoot',
|
||||
'db:reopenWithRoot',
|
||||
'api:skillStatus',
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { dirname, join } from 'path'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Message } from '../../src/shared/types'
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
documents: '',
|
||||
videoPath: '',
|
||||
messages: [] as Message[],
|
||||
imageLookups: [] as { allowThumbnail?: boolean; preferThumbnail?: boolean }[]
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => state.documents },
|
||||
shell: { showItemInFolder: vi.fn() },
|
||||
BrowserWindow: class {}
|
||||
}))
|
||||
vi.mock('../../src/main/services/chat-service', () => ({
|
||||
listMessages: () => structuredClone(state.messages),
|
||||
getChatDb: () => ({ getWcdb4Client: () => ({}) }),
|
||||
getContactAvatars: () => ({})
|
||||
}))
|
||||
vi.mock('../../src/main/services/image-key-config-service', () => ({
|
||||
ImageKeyConfigService: class {
|
||||
getConfig(): { aesKey: string; xorKey: string } {
|
||||
return { aesKey: '0123456789abcdef', xorKey: '0x40' }
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('../../src/main/voice-service', () => ({
|
||||
VoiceService: class {
|
||||
async resolveVoice(
|
||||
_sessionId: string,
|
||||
localId: number
|
||||
): Promise<{ success: boolean; data?: string; error?: string }> {
|
||||
return localId === 1
|
||||
? {
|
||||
success: true,
|
||||
data: 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA='
|
||||
}
|
||||
: { success: false, error: '本地未找到语音数据' }
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('../../src/main/image-decrypt-service', () => ({
|
||||
ImageDecryptService: class {
|
||||
findImageFile(
|
||||
_md5: string,
|
||||
_datName: string,
|
||||
options: { allowThumbnail?: boolean; preferThumbnail?: boolean }
|
||||
): string {
|
||||
state.imageLookups.push(options)
|
||||
return 'fixture-original.dat'
|
||||
}
|
||||
decryptImageToBase64WithFallback(): { data: string; filePath: string } {
|
||||
return {
|
||||
data: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=',
|
||||
filePath: 'fixture-original.dat'
|
||||
}
|
||||
}
|
||||
isThumbnailFile(): boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('../../src/main/video-asset-service', () => ({
|
||||
VideoAssetService: class {
|
||||
resolve(): { success: boolean; url: string } {
|
||||
return { success: true, url: 'wxe-media://local/fixture-video' }
|
||||
}
|
||||
pathForUrl(): string {
|
||||
return state.videoPath
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('../../src/main/sticker-service', () => ({
|
||||
StickerService: class {}
|
||||
}))
|
||||
|
||||
const message = (overrides: Partial<Message>): Message => ({
|
||||
id: 'fixture',
|
||||
from: 'fixture',
|
||||
type: '普通文本',
|
||||
datetime: '2026-08-01 10:00:00',
|
||||
content: '',
|
||||
isSender: false,
|
||||
createTime: 1_785_549_600,
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('media export flow', () => {
|
||||
beforeEach(() => {
|
||||
state.documents = mkdtempSync(join(tmpdir(), 'wxe-export-fixture-'))
|
||||
state.videoPath = join(state.documents, 'fixture.mp4')
|
||||
writeFileSync(
|
||||
state.videoPath,
|
||||
Buffer.from('000000186674797069736f6d0000020069736f6d69736f32', 'hex')
|
||||
)
|
||||
state.imageLookups = []
|
||||
state.messages = [
|
||||
message({
|
||||
id: 'voice-ok',
|
||||
type: '语音',
|
||||
sessionId: 'fixture-session',
|
||||
localId: 1,
|
||||
contentData: { type: 'voice', duration: 1 }
|
||||
}),
|
||||
message({
|
||||
id: 'voice-missing',
|
||||
type: '语音',
|
||||
sessionId: 'fixture-session',
|
||||
localId: 2,
|
||||
contentData: { type: 'voice', duration: 1 }
|
||||
}),
|
||||
message({
|
||||
id: 'image',
|
||||
type: '图片',
|
||||
sessionId: 'fixture-session',
|
||||
contentData: { type: 'image', md5: 'a'.repeat(32), datName: 'fixture.dat' }
|
||||
}),
|
||||
message({
|
||||
id: 'video',
|
||||
type: '视频',
|
||||
contentData: { type: 'video', md5: 'b'.repeat(32) }
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
afterEach(() => rmSync(state.documents, { recursive: true, force: true }))
|
||||
|
||||
it('writes playable relative assets, keeps failures, and requests the original image first', async () => {
|
||||
const { runExport } = await import('../../src/main/export-service')
|
||||
const progress: unknown[] = []
|
||||
const win = {
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: (...args: unknown[]) => progress.push(args) }
|
||||
}
|
||||
const result = await runExport(
|
||||
{
|
||||
jobId: 'fixture-export',
|
||||
userMd5: 'fixture-user',
|
||||
name: '脱敏会话',
|
||||
format: 'html',
|
||||
outputName: 'fixture',
|
||||
kinds: ['voice', 'image', 'video'],
|
||||
includeMedia: true,
|
||||
preferOriginal: true,
|
||||
fallbackThumbnail: true,
|
||||
keepMissing: true
|
||||
},
|
||||
win as never
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const html = readFileSync(result.outputPath!, 'utf8')
|
||||
const outputDir = dirname(result.outputPath!)
|
||||
expect(readFileSync(join(outputDir, 'voices/voice_1_1.wav')).subarray(0, 4).toString()).toBe(
|
||||
'RIFF'
|
||||
)
|
||||
expect(readFileSync(join(outputDir, 'media/video_4.mp4')).subarray(4, 8).toString()).toBe(
|
||||
'ftyp'
|
||||
)
|
||||
expect(html).toContain('src="voices/voice_1_1.wav"')
|
||||
expect(html).toContain('src="media/video_4.mp4"')
|
||||
expect(html).toContain('语音文件缺失:本地未找到语音数据')
|
||||
expect(state.imageLookups[0]).toMatchObject({
|
||||
allowThumbnail: false,
|
||||
preferThumbnail: false
|
||||
})
|
||||
expect(progress.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import fs from 'fs-extra'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocked = vi.hoisted(() => ({
|
||||
userData: `${process.env.TEMP || process.env.TMP || '.'}/wxe-account-discovery-tests`
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => mocked.userData }
|
||||
}))
|
||||
|
||||
import { discoverAccounts } from '../../src/main/services/account-discovery'
|
||||
|
||||
describe('account discovery', () => {
|
||||
let root: string
|
||||
const keyStore = {
|
||||
getStatus: vi.fn(async (accountRoot: string) => ({
|
||||
saved: accountRoot.endsWith('account-b'),
|
||||
encryptionAvailable: true
|
||||
}))
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), 'wxe-accounts-'))
|
||||
await Promise.all(
|
||||
['account-a', 'account-b', 'account-c'].map((name) =>
|
||||
fs.ensureDir(path.join(root, name, 'db_storage'))
|
||||
)
|
||||
)
|
||||
await fs.ensureDir(path.join(root, 'Backup'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.remove(root)
|
||||
await fs.remove(mocked.userData)
|
||||
})
|
||||
|
||||
it('rejects an invalid Backup directory without continuing', async () => {
|
||||
const result = await discoverAccounts(path.join(root, 'Backup'), keyStore as never)
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.accounts).toEqual([])
|
||||
})
|
||||
|
||||
it('lists every direct account and never preselects one from a root directory', async () => {
|
||||
const result = await discoverAccounts(root, keyStore as never)
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.accounts.map((account) => account.directoryName).sort()).toEqual([
|
||||
'account-a',
|
||||
'account-b',
|
||||
'account-c'
|
||||
])
|
||||
expect(result.preselectedAccountId).toBeUndefined()
|
||||
expect(
|
||||
result.accounts.find((account) => account.directoryName === 'account-b')?.hasSavedDbKey
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('preselects a directly selected account directory while retaining its card', async () => {
|
||||
const accountRoot = path.join(root, 'account-c')
|
||||
const result = await discoverAccounts(accountRoot, keyStore as never)
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.accounts).toHaveLength(1)
|
||||
expect(result.accounts[0].accountRoot).toBe(accountRoot)
|
||||
expect(result.preselectedAccountId).toBe(result.accounts[0].id)
|
||||
expect(result.accounts[0].selectedByInput).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildSafeDiagnosticSummary } from '../../src/shared/connection-diagnostics'
|
||||
|
||||
describe('connection diagnostics', () => {
|
||||
it('contains useful versions and readiness without secrets or full account paths', () => {
|
||||
const summary = buildSafeDiagnosticSummary({
|
||||
platform: 'win32',
|
||||
osVersion: 'Windows 11 fixture',
|
||||
appVersion: 'v2.1.6',
|
||||
wechatVersion: '4.1.9.57',
|
||||
dataStructureVersion: '微信 4.x(WCDB)',
|
||||
dataDirectoryDetected: true,
|
||||
autoDetectSupported: true,
|
||||
wechatRunning: true,
|
||||
accountIdentified: true,
|
||||
dbConnected: false,
|
||||
encryptionAvailable: true
|
||||
})
|
||||
|
||||
expect(summary).toContain('WechatExplorer: v2.1.6')
|
||||
expect(summary).toContain('微信客户端: 4.1.9.57')
|
||||
expect(summary).not.toContain('0123456789abcdef')
|
||||
expect(summary).not.toContain('C:\\Users\\fixture\\xwechat_files\\wxid_secret')
|
||||
expect(summary).not.toContain('wxid_')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderExportPage } from '../../src/main/export-html-template'
|
||||
import { getImageExportAttempts } from '../../src/shared/export-media'
|
||||
import type { Message } from '../../src/shared/types'
|
||||
|
||||
const baseMessage = (overrides: Partial<Message>): Message => ({
|
||||
id: 'fixture-message',
|
||||
from: 'fixture',
|
||||
type: '文本',
|
||||
datetime: '2026-08-01 10:00:00',
|
||||
content: '',
|
||||
isSender: false,
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('export media', () => {
|
||||
it('always attempts the original before an explicitly enabled thumbnail fallback', () => {
|
||||
const first = getImageExportAttempts({ preferOriginal: true, fallbackThumbnail: true })
|
||||
const repeated = getImageExportAttempts({ preferOriginal: true, fallbackThumbnail: true })
|
||||
|
||||
expect(first).toEqual([
|
||||
{ allowThumbnail: false, preferThumbnail: false, fallback: false },
|
||||
{ allowThumbnail: true, preferThumbnail: true, fallback: true }
|
||||
])
|
||||
expect(repeated).toEqual(first)
|
||||
})
|
||||
|
||||
it('renders movable relative audio and video assets plus accurate missing-media details', () => {
|
||||
const html = renderExportPage('脱敏导出', [
|
||||
baseMessage({ id: 'voice', type: '语音', voiceDataUrl: 'voices/voice_1.wav' }),
|
||||
baseMessage({
|
||||
id: 'video',
|
||||
type: '视频',
|
||||
exportMediaType: 'video',
|
||||
exportMediaUrl: 'media/video_2.mp4'
|
||||
}),
|
||||
baseMessage({
|
||||
id: 'missing',
|
||||
type: '语音',
|
||||
exportMediaError: '语音文件缺失:本地未找到语音数据'
|
||||
})
|
||||
])
|
||||
|
||||
expect(html).toContain(
|
||||
'audio class="audio" controls preload="metadata" src="voices/voice_1.wav"'
|
||||
)
|
||||
expect(html).toContain('video class="media-image" controls src="media/video_2.mp4"')
|
||||
expect(html).toContain('语音文件缺失:本地未找到语音数据')
|
||||
expect(html).not.toMatch(/(?:src|href)="[A-Za-z]:\\/)
|
||||
})
|
||||
|
||||
it('renders explicit and keyboard-accessible lightbox closing controls', () => {
|
||||
const html = renderExportPage('图片预览', [
|
||||
baseMessage({ id: 'image', type: '图片', exportMediaUrl: 'media/image.jpg' })
|
||||
])
|
||||
|
||||
expect(html).toContain('aria-label="关闭图片预览"')
|
||||
expect(html).toContain("closeButton.addEventListener('click',closeLightbox)")
|
||||
expect(html).toContain('if(event.target===box)closeLightbox()')
|
||||
expect(html).toContain("if(event.key==='Escape')closeLightbox()")
|
||||
})
|
||||
})
|
||||
@@ -20,4 +20,14 @@ describe('message pagination', () => {
|
||||
)
|
||||
expect(merged.map((message) => message.id)).toEqual(['oldest', 'overlap', 'latest'])
|
||||
})
|
||||
|
||||
it('keeps cross-year pages continuous through the earliest fixture record', () => {
|
||||
const page2025 = [makeMessage('2025', 1_735_689_600), makeMessage('2026', 1_767_225_600)]
|
||||
const page2017 = [makeMessage('2017', 1_483_228_800), makeMessage('2025', 1_735_689_600)]
|
||||
|
||||
const merged = mergeMessagePages(page2017, page2025)
|
||||
|
||||
expect(merged.map((message) => message.id)).toEqual(['2017', '2025', '2026'])
|
||||
expect(new Set(merged.map((message) => message.id)).size).toBe(merged.length)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Wcdb4Client, type Wcdb4Message } from '../../src/main/wcdb4-client'
|
||||
|
||||
const message = (id: string, year: number): Wcdb4Message => ({
|
||||
mesLocalID: id,
|
||||
serverId: `server-${id}`,
|
||||
mesDes: 0,
|
||||
messageType: '1',
|
||||
msgCreateTime: String(Math.floor(Date.UTC(year, 0, 1) / 1000)),
|
||||
msgContent: `fixture-${year}`,
|
||||
raw: {}
|
||||
})
|
||||
|
||||
describe('WCDB message shard pagination', () => {
|
||||
it('merges cursor and all-store rows for a bounded cross-year page', async () => {
|
||||
const cursor = vi.fn(async () => [message('2025', 2025)])
|
||||
const tableScan = vi.fn(async () => [message('2017', 2017), message('2025', 2025)])
|
||||
const client = Object.assign(Object.create(Wcdb4Client.prototype), {
|
||||
wcdbGetMessageTableStats: vi.fn(),
|
||||
wcdbExecQuery: vi.fn(),
|
||||
getMessagesByCursorAsync: cursor,
|
||||
getMessagesByTableScanAsync: tableScan
|
||||
}) as Wcdb4Client
|
||||
|
||||
const result = await client.getMessagesAsync(
|
||||
'fixture@chatroom',
|
||||
undefined,
|
||||
Math.floor(Date.UTC(2026, 0, 1) / 1000),
|
||||
{ limit: 20 }
|
||||
)
|
||||
|
||||
expect(tableScan).toHaveBeenCalledOnce()
|
||||
expect(result.map((item) => item.msgContent)).toEqual(['fixture-2017', 'fixture-2025'])
|
||||
})
|
||||
|
||||
it('reports an unsupported shard query instead of claiming history ended', async () => {
|
||||
const client = Object.assign(Object.create(Wcdb4Client.prototype), {
|
||||
getMessagesByCursorAsync: vi.fn(async () => [])
|
||||
}) as Wcdb4Client
|
||||
|
||||
await expect(
|
||||
client.getMessagesAsync('fixture@chatroom', undefined, 1_767_225_600, { limit: 20 })
|
||||
).rejects.toThrow('无法检查历史消息分片')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user