mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 03:57:02 +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` 并按安装向导完成安装。
|
应用安装包:[WechatExplorer GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases)。Windows 选择 `-setup.exe`,macOS 按处理器架构选择对应 `.dmg`。
|
||||||
- macOS:下载 `.dmg`,将 WechatExplorer 拖入“应用程序”。首次打开若被系统拦截,请在“系统设置 → 隐私与安全性”中允许打开。
|
|
||||||
|
| 系统 | 已测试的微信客户端 |
|
||||||
|
| ------- | ------------------------------------------------------------------------------------------------- |
|
||||||
|
| 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)
|
- [我想让 Agent 读取微信](#接入-api-reader-skill-或-agent)
|
||||||
|
|
||||||
|
> 正常覆盖安装只会替换应用程序文件,WechatExplorer / 迹忆不会主动删除或修改微信原始聊天记录。应用缓存和本地设置可能随版本升级发生变化。系统故障、磁盘异常、误操作和微信自身迁移不受本应用控制,因此升级前仍建议使用微信官方迁移或备份功能备份重要聊天记录,不要将唯一副本保存在单一设备。
|
||||||
|
|
||||||
## 开始前确认
|
## 开始前确认
|
||||||
|
|
||||||
| 系统 | 已测试的微信版本 | 需要注意 |
|
| 系统 | 已测试的微信客户端 | 需要注意 |
|
||||||
| --- | --- | --- |
|
| ------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------- |
|
||||||
| macOS | `4.1.8.100` | 自动获取数据库密钥前需要关闭 SIP 并完成授权 |
|
| macOS | [微信 macOS `4.1.8.100`](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100) | 自动获取数据库密钥前需要关闭 SIP 并完成授权 |
|
||||||
| Windows | `4.1.9.57` | 已完整支持;首次使用时请确认微信数据目录 |
|
| Windows | [微信 Windows `4.1.9.57`](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57) | 已完整支持;首次使用时请确认微信数据目录 |
|
||||||
|
|
||||||
- WechatExplorer 当前面向微信 4.0 数据结构。
|
- WechatExplorer 当前面向微信 4.0 数据结构。
|
||||||
- Windows 不需要关闭 SIP。
|
- Windows 不需要关闭 SIP。
|
||||||
@@ -26,7 +28,7 @@
|
|||||||
- WechatExplorer 必须取得当前微信账号对应的数据库密钥才能读取聊天记录。
|
- 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 重新运行安装包 |
|
| 软件打不开 | macOS 安全提示或应用损坏处理;Windows 重新运行安装包 |
|
||||||
| 找不到微信数据 | 在首次连接页面或设置中确认数据目录,Windows 检查目录层级 |
|
| 找不到微信数据 | 在首次连接页面或设置中确认数据目录,Windows 检查目录层级 |
|
||||||
| 获取不到数据库密钥 | 微信是否停留在登录页面、微信和应用是否同时运行 |
|
| 获取不到数据库密钥 | 微信是否停留在登录页面、微信和应用是否同时运行 |
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { app, safeStorage } from 'electron'
|
import { app, safeStorage } from 'electron'
|
||||||
import fs from 'fs-extra'
|
import fs from 'fs-extra'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import crypto from 'crypto'
|
||||||
import type { DatabaseKeyStorageResult } from '../shared/database-key'
|
import type { DatabaseKeyStorageResult } from '../shared/database-key'
|
||||||
|
|
||||||
const normalizeDatabaseKey = (value: string): string => value.trim().replace(/^0x/i, '')
|
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))
|
/^[0-9a-f]{64}$/i.test(normalizeDatabaseKey(value))
|
||||||
|
|
||||||
export class DatabaseKeyStore {
|
export class DatabaseKeyStore {
|
||||||
private get filePath(): string {
|
private get legacyFilePath(): string {
|
||||||
return path.join(app.getPath('userData'), 'wechat-db-key.bin')
|
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 {
|
return {
|
||||||
saved: await fs.pathExists(this.filePath),
|
saved: Boolean(accountRoot) && (await fs.pathExists(this.filePath(accountRoot))),
|
||||||
encryptionAvailable: safeStorage.isEncryptionAvailable()
|
encryptionAvailable: safeStorage.isEncryptionAvailable()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async load(): Promise<DatabaseKeyStorageResult> {
|
async load(accountRoot: string): Promise<DatabaseKeyStorageResult> {
|
||||||
try {
|
try {
|
||||||
const status = await this.getStatus()
|
const status = await this.getStatus(accountRoot)
|
||||||
if (!status.saved) return { success: true, ...status }
|
if (!status.saved) return { success: true, ...status }
|
||||||
if (!status.encryptionAvailable) {
|
if (!status.encryptionAvailable) {
|
||||||
return { success: false, error: '系统安全存储不可用', ...status }
|
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))
|
const key = normalizeDatabaseKey(safeStorage.decryptString(encrypted))
|
||||||
if (!isValidDatabaseKey(key)) {
|
if (!isValidDatabaseKey(key)) {
|
||||||
return { success: false, error: '已保存的密钥格式无效', ...status }
|
return { success: false, error: '已保存的密钥格式无效', ...status }
|
||||||
}
|
}
|
||||||
return { success: true, key, ...status }
|
return { success: true, key, ...status }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = await this.getStatus()
|
const status = await this.getStatus(accountRoot)
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : String(error),
|
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)
|
const key = normalizeDatabaseKey(rawKey)
|
||||||
|
if (!accountRoot.trim()) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '请先选择微信账号',
|
||||||
|
saved: false,
|
||||||
|
encryptionAvailable: safeStorage.isEncryptionAvailable()
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!isValidDatabaseKey(key)) {
|
if (!isValidDatabaseKey(key)) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: '密钥必须是 64 位十六进制字符',
|
error: '密钥必须是 64 位十六进制字符',
|
||||||
saved: await fs.pathExists(this.filePath),
|
saved: await fs.pathExists(this.filePath(accountRoot)),
|
||||||
encryptionAvailable: safeStorage.isEncryptionAvailable()
|
encryptionAvailable: safeStorage.isEncryptionAvailable()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,29 +104,30 @@ export class DatabaseKeyStore {
|
|||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: '系统安全存储不可用',
|
error: '系统安全存储不可用',
|
||||||
saved: await fs.pathExists(this.filePath),
|
saved: await fs.pathExists(this.filePath(accountRoot)),
|
||||||
encryptionAvailable: false
|
encryptionAvailable: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.ensureDir(path.dirname(this.filePath))
|
const filePath = this.filePath(accountRoot)
|
||||||
await fs.writeFile(this.filePath, safeStorage.encryptString(key), { mode: 0o600 })
|
await fs.ensureDir(this.directoryPath)
|
||||||
await fs.chmod(this.filePath, 0o600)
|
await fs.writeFile(filePath, safeStorage.encryptString(key), { mode: 0o600 })
|
||||||
|
await fs.chmod(filePath, 0o600)
|
||||||
return { success: true, key, saved: true, encryptionAvailable: true }
|
return { success: true, key, saved: true, encryptionAvailable: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
saved: await fs.pathExists(this.filePath),
|
saved: await fs.pathExists(this.filePath(accountRoot)),
|
||||||
encryptionAvailable: true
|
encryptionAvailable: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async clear(): Promise<{ success: boolean; error?: string }> {
|
async clear(accountRoot: string): Promise<{ success: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
await fs.remove(this.filePath)
|
if (accountRoot) await fs.remove(this.filePath(accountRoot))
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Message } from '../shared/types'
|
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 =>
|
const safe = (value: unknown): string =>
|
||||||
String(value ?? '').replace(
|
String(value ?? '').replace(
|
||||||
/[&<>"']/g,
|
/[&<>"']/g,
|
||||||
@@ -14,7 +14,10 @@ export function renderExportPage(name: string, messages: Message[]): string {
|
|||||||
? `<img src="${safe(m.img)}" alt="">`
|
? `<img src="${safe(m.img)}" alt="">`
|
||||||
: safe((m.name || (m.isSender ? '我' : '友')).slice(0, 1))
|
: safe((m.name || (m.isSender ? '我' : '友')).slice(0, 1))
|
||||||
const audio = m.voiceDataUrl
|
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 =
|
const quote =
|
||||||
m.contentData?.type === '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>`
|
: `<div class="avatar">${m.exportAvatarUrl ? `<img src="${safe(m.exportAvatarUrl)}" alt="">` : avatar}</div>`
|
||||||
const isPat = m.contentData?.type === 'system' && m.contentData.pat
|
const isPat = m.contentData?.type === 'system' && m.contentData.pat
|
||||||
const text = m.content || (m.contentData?.type === 'quote' ? m.contentData.title : '')
|
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('')
|
.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 { ImageKeyConfigService } from './services/image-key-config-service'
|
||||||
import { VideoAssetService } from './video-asset-service'
|
import { VideoAssetService } from './video-asset-service'
|
||||||
import { StickerService } from './sticker-service'
|
import { StickerService } from './sticker-service'
|
||||||
|
import { getImageExportAttempts } from '../shared/export-media'
|
||||||
|
|
||||||
const jobs = new Set<string>()
|
const jobs = new Set<string>()
|
||||||
const safeFilePart = (value: string): 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())}`
|
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
|
||||||
}
|
}
|
||||||
const imageKeys = new ImageKeyConfigService()
|
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 {
|
function decodeDataUrl(data: string): { extension: string; buffer: Buffer } | null {
|
||||||
const match = /^data:([^;]+);base64,(.+)$/s.exec(data)
|
const match = /^data:([^;]+);base64,(.+)$/s.exec(data)
|
||||||
if (!match) return null
|
if (!match) return null
|
||||||
@@ -105,11 +110,20 @@ function render(format: ExportRequest['format'], messages: Message[], name: stri
|
|||||||
if (format === 'json')
|
if (format === 'json')
|
||||||
return JSON.stringify({ name, exportedAt: new Date().toISOString(), messages }, null, 2)
|
return JSON.stringify({ name, exportedAt: new Date().toISOString(), messages }, null, 2)
|
||||||
if (format === 'markdown')
|
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 [
|
return [
|
||||||
'时间,发送者,类型,内容',
|
'时间,发送者,类型,内容,媒体路径,媒体状态',
|
||||||
...messages.map((m) =>
|
...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')
|
].join('\n')
|
||||||
}
|
}
|
||||||
@@ -126,9 +140,19 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
|||||||
.listMessages(request.userMd5, request.startTime, request.endTime)
|
.listMessages(request.userMd5, request.startTime, request.endTime)
|
||||||
.filter((m) => request.kinds.includes(kindOf(m)))
|
.filter((m) => request.kinds.includes(kindOf(m)))
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
|
message.exportMediaUrl = undefined
|
||||||
|
message.exportMediaType = undefined
|
||||||
|
message.exportMediaError = undefined
|
||||||
|
message.voiceDataUrl = undefined
|
||||||
message.exportShowAvatar = request.includeAvatars !== false
|
message.exportShowAvatar = request.includeAvatars !== false
|
||||||
const mappedName = message.senderId ? request.nameMap?.[message.senderId] : undefined
|
const mappedName = message.senderId ? request.nameMap?.[message.senderId] : undefined
|
||||||
if (mappedName) message.name = mappedName
|
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 })
|
send({ jobId: request.jobId, phase: 'reading', processed: 10, total: 100, percent: 10 })
|
||||||
if (!jobs.has(request.jobId)) {
|
if (!jobs.has(request.jobId)) {
|
||||||
@@ -181,25 +205,46 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
|||||||
: null
|
: null
|
||||||
if (voiceService) {
|
if (voiceService) {
|
||||||
for (const [index, message] of messages.entries()) {
|
for (const [index, message] of messages.entries()) {
|
||||||
if (
|
if (kindOf(message) !== 'voice') continue
|
||||||
kindOf(message) !== 'voice' ||
|
if (!message.sessionId || message.localId == null || !message.createTime) {
|
||||||
!message.sessionId ||
|
keepMediaError(request, message, '语音标识不完整,无法定位本地语音')
|
||||||
!message.localId ||
|
|
||||||
!message.createTime
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
|
}
|
||||||
|
try {
|
||||||
const voice = await voiceService.resolveVoice(
|
const voice = await voiceService.resolveVoice(
|
||||||
message.sessionId,
|
message.sessionId,
|
||||||
message.localId,
|
message.localId,
|
||||||
message.createTime,
|
message.createTime,
|
||||||
message.serverId
|
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 voiceName = `voice_${index + 1}_${message.localId}.wav`
|
||||||
const audioBuffer = Buffer.from(voice.data, 'base64')
|
const audioBuffer = Buffer.from(voice.data, 'base64')
|
||||||
await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer)
|
await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer)
|
||||||
message.voiceDataUrl = `voices/${voiceName}`
|
message.voiceDataUrl = `voices/${voiceName}`
|
||||||
message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2)))
|
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()) {
|
for (const [index, message] of messages.entries()) {
|
||||||
@@ -228,35 +273,79 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
|||||||
})
|
})
|
||||||
continue
|
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(
|
const file = imageService.findImageFile(
|
||||||
message.contentData.md5,
|
message.contentData.md5,
|
||||||
message.contentData.datName,
|
message.contentData.datName,
|
||||||
{ allowThumbnail: true }
|
{
|
||||||
|
allowThumbnail: attempt.allowThumbnail,
|
||||||
|
preferThumbnail: attempt.preferThumbnail,
|
||||||
|
sessionId: message.sessionId
|
||||||
|
}
|
||||||
)
|
)
|
||||||
const decrypted = file ? imageService.decryptImageToBase64WithFallback(file, true) : null
|
if (!file) continue
|
||||||
const decoded = decrypted ? decodeDataUrl(decrypted.data) : null
|
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) {
|
if (decoded) {
|
||||||
const name = `image_${index + 1}.${decoded.extension}`
|
const name = `image_${index + 1}.${decoded.extension}`
|
||||||
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
|
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
|
||||||
message.exportMediaUrl = `media/${name}`
|
message.exportMediaUrl = `media/${name}`
|
||||||
message.exportMediaType = 'image'
|
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 = [
|
const hashes = [
|
||||||
message.contentData.md5,
|
message.contentData.md5,
|
||||||
message.contentData.newMd5,
|
message.contentData.newMd5,
|
||||||
message.contentData.rawMd5
|
message.contentData.rawMd5
|
||||||
].filter((value): value is string => Boolean(value))
|
].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 resolved = videoService.resolve(hashes)
|
||||||
const token = resolved.url?.split('/').pop()
|
const source = resolved.url ? videoService.pathForUrl(resolved.url) : undefined
|
||||||
const source = token ? videoService.pathForToken(token) : undefined
|
if (!resolved.success || !source) {
|
||||||
if (source) {
|
keepMediaError(request, message, resolved.error || '视频文件缺失或已移动')
|
||||||
|
} else if (extname(source).toLowerCase() !== '.mp4') {
|
||||||
|
keepMediaError(request, message, '视频格式不支持,仅支持本地 MP4 文件')
|
||||||
|
} else {
|
||||||
const name = `video_${index + 1}.mp4`
|
const name = `video_${index + 1}.mp4`
|
||||||
await fs.copyFile(source, join(outputDir, 'media', name))
|
await fs.copyFile(source, join(outputDir, 'media', name))
|
||||||
message.exportMediaUrl = `media/${name}`
|
message.exportMediaUrl = `media/${name}`
|
||||||
message.exportMediaType = 'video'
|
message.exportMediaType = 'video'
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else if (message.contentData.type === 'sticker' && stickerService) {
|
} else if (message.contentData.type === 'sticker' && stickerService) {
|
||||||
const stickerSource = message.contentData.url || message.contentData.thumbUrl
|
const stickerSource = message.contentData.url || message.contentData.thumbUrl
|
||||||
const result = await stickerService.resolveSticker(stickerSource, message.contentData.md5)
|
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)
|
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
|
||||||
message.exportMediaUrl = `media/${name}`
|
message.exportMediaUrl = `media/${name}`
|
||||||
message.exportMediaType = 'sticker'
|
message.exportMediaType = 'sticker'
|
||||||
|
} else {
|
||||||
|
keepMediaError(request, message, result.error || '表情资源缺失或下载失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
send({
|
send({
|
||||||
|
|||||||
+84
-18
@@ -58,13 +58,25 @@ import * as chat from './services/chat-service'
|
|||||||
import { apiServer } from './http-server'
|
import { apiServer } from './http-server'
|
||||||
import { skillResourceService } from './services/skill-resource-service'
|
import { skillResourceService } from './services/skill-resource-service'
|
||||||
import { testLocalApiRequest } from './services/local-api-test-service'
|
import { testLocalApiRequest } from './services/local-api-test-service'
|
||||||
import { isWindowsWechatRunning } from './services/wechat-process-status'
|
import { isWechatRunning } from './services/wechat-process-status'
|
||||||
import {
|
import {
|
||||||
inspectImageDecryptionStatus,
|
inspectImageDecryptionStatus,
|
||||||
testImageDecryption
|
testImageDecryption
|
||||||
} from './services/image-decryption-status-service'
|
} from './services/image-decryption-status-service'
|
||||||
import type { SaveImageKeyRequest, TestImageDecryptionRequest } from '../shared/image-decryption'
|
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 {
|
import {
|
||||||
flushBootstrapCacheWritesSync,
|
flushBootstrapCacheWritesSync,
|
||||||
getBootstrapCache,
|
getBootstrapCache,
|
||||||
@@ -88,6 +100,7 @@ import { configureRecallArchive, RecallArchiveMonitor } from './services/recall-
|
|||||||
import { VideoAssetService } from './video-asset-service'
|
import { VideoAssetService } from './video-asset-service'
|
||||||
import { cancelExport, revealExport, runExport } from './export-service'
|
import { cancelExport, revealExport, runExport } from './export-service'
|
||||||
import type { ExportRequest } from '../shared/export'
|
import type { ExportRequest } from '../shared/export'
|
||||||
|
import { discoverAccounts } from './services/account-discovery'
|
||||||
|
|
||||||
// electron-vite can close the child's stdout/stderr after spawning Electron.
|
// 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
|
// 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)
|
return clearCache(scope)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('db:init', async (_, key: string) => {
|
ipcMain.handle('db:init', async (_, key: string, accountRoot?: string) => {
|
||||||
if (dbInitInFlight) return dbInitInFlight
|
if (dbInitInFlight) return dbInitInFlight
|
||||||
|
|
||||||
dbInitInFlight = (async () => {
|
dbInitInFlight = (async () => {
|
||||||
@@ -489,17 +502,35 @@ app.whenReady().then(async () => {
|
|||||||
const trimmedKey = String(key || '').trim()
|
const trimmedKey = String(key || '').trim()
|
||||||
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
|
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
|
||||||
const settings = loadSettings()
|
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 (
|
if (
|
||||||
chat.isReady() &&
|
chat.isReady() &&
|
||||||
chat.getCurrentKey().replace(/^0x/i, '').trim() === trimmedKey.replace(/^0x/i, '') &&
|
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')
|
console.log('[WCDB4] db:init reuse current connection')
|
||||||
return { success: true, monitoring: true }
|
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()
|
const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot()
|
||||||
if (resolvedRoot && resolvedRoot !== settings.dbRoot) {
|
if (resolvedRoot) {
|
||||||
// 同步更新 imageKeyRoot,避免自动获取图片密钥时扫描到错误目录
|
// 同步更新 imageKeyRoot,避免自动获取图片密钥时扫描到错误目录
|
||||||
saveSettings({
|
saveSettings({
|
||||||
...settings,
|
...settings,
|
||||||
@@ -543,19 +574,43 @@ app.whenReady().then(async () => {
|
|||||||
return dbInitInFlight
|
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 () => {
|
ipcMain.handle('key:getEnvironment', async () => {
|
||||||
const storage = await databaseKeyStore.getStatus()
|
const storage = await databaseKeyStore.getStatus(
|
||||||
|
chat.getCurrentAccountRoot() || loadSettings().dbRoot
|
||||||
|
)
|
||||||
const self = chat.getSelfAccountInfo()
|
const self = chat.getSelfAccountInfo()
|
||||||
return {
|
const settings = loadSettings()
|
||||||
|
const environment = {
|
||||||
platform: process.platform,
|
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',
|
autoDetectSupported: process.platform === 'win32',
|
||||||
wechatRunning: await isWindowsWechatRunning(),
|
wechatRunning: await isWechatRunning(),
|
||||||
accountIdentified: Boolean(self?.wxid),
|
accountIdentified: Boolean(self?.wxid),
|
||||||
dbConnected: chat.isReady(),
|
dbConnected: chat.isReady(),
|
||||||
encryptionAvailable: storage.encryptionAvailable
|
encryptionAvailable: storage.encryptionAvailable
|
||||||
}
|
}
|
||||||
|
return { ...environment, diagnosticSummary: buildSafeDiagnosticSummary(environment) }
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('key:readClipboardDbKey', () => {
|
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()
|
const clipboardKey = clipboard.readText().trim()
|
||||||
return databaseKeyStore.save(clipboardKey)
|
return databaseKeyStore.save(String(accountRoot || ''), clipboardKey)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('key:saveDbKey', async (_, key: string) =>
|
ipcMain.handle('key:saveDbKey', async (_, accountRoot: string, key: string) =>
|
||||||
databaseKeyStore.save(String(key || ''))
|
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 => {
|
const onStatus = (message: string): void => {
|
||||||
if (!event.sender.isDestroyed()) event.sender.send('key:dbKeyStatus', { message })
|
if (!event.sender.isDestroyed()) event.sender.send('key:dbKeyStatus', { message })
|
||||||
}
|
}
|
||||||
@@ -587,15 +646,22 @@ app.whenReady().then(async () => {
|
|||||||
: await keyServiceMac.autoGetDbKey(onStatus)
|
: await keyServiceMac.autoGetDbKey(onStatus)
|
||||||
if (!result.success || !result.key) return result
|
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
|
if (options?.save === false) return result
|
||||||
|
|
||||||
const saved = await databaseKeyStore.save(result.key)
|
const saved = await databaseKeyStore.save(selectedRoot, result.key)
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
saved: saved.success,
|
saved: saved.success,
|
||||||
warning: saved.success ? undefined : saved.error
|
warning: saved.success ? undefined : saved.error
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
ipcMain.handle('key:autoGetImageKey', async (event, options?: { save?: boolean }) => {
|
ipcMain.handle('key:autoGetImageKey', async (event, options?: { save?: boolean }) => {
|
||||||
const settings = loadSettings()
|
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))
|
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 (!candidate || !fs.existsSync(candidate)) return false
|
||||||
if (fs.existsSync(path.join(candidate, 'db_storage'))) return true
|
if (fs.existsSync(path.join(candidate, 'db_storage'))) return true
|
||||||
try {
|
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 defaultDbRoot = getDefaultDbRoot()
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: AppSettings = {
|
const DEFAULT_SETTINGS: AppSettings = {
|
||||||
|
|||||||
@@ -938,9 +938,27 @@ export class Wcdb4Client {
|
|||||||
): Promise<Wcdb4Message[]> {
|
): Promise<Wcdb4Message[]> {
|
||||||
const startedAt = Date.now()
|
const startedAt = Date.now()
|
||||||
const maxRows = this.normalizeMessageLimit(options.limit)
|
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(
|
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
|
return messages
|
||||||
}
|
}
|
||||||
@@ -998,6 +1016,71 @@ export class Wcdb4Client {
|
|||||||
return this.finalizeMessages(username, allRows, startTime, endTime, limit)
|
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 } {
|
installRecallJournal(usernames: string[]): { installed: number; failed: number } {
|
||||||
const stores = new Map<string, Wcdb4MessageStore>()
|
const stores = new Map<string, Wcdb4MessageStore>()
|
||||||
for (const username of this.uniq(usernames)) {
|
for (const username of this.uniq(usernames)) {
|
||||||
|
|||||||
Vendored
+15
-7
@@ -11,7 +11,8 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
DatabaseKeyEnvironment,
|
DatabaseKeyEnvironment,
|
||||||
DatabaseKeyStorageResult,
|
DatabaseKeyStorageResult,
|
||||||
DatabaseKeyValidationResult
|
DatabaseKeyValidationResult,
|
||||||
|
AccountDiscoveryResult
|
||||||
} from '../shared/database-key'
|
} from '../shared/database-key'
|
||||||
import type {
|
import type {
|
||||||
ImageDecoderSelectionResult,
|
ImageDecoderSelectionResult,
|
||||||
@@ -115,8 +116,10 @@ declare global {
|
|||||||
getCacheSummary: () => Promise<CacheSummary>
|
getCacheSummary: () => Promise<CacheSummary>
|
||||||
clearCache: (scope: 'bootstrap' | 'electron' | 'all') => Promise<CacheSummary>
|
clearCache: (scope: 'bootstrap' | 'electron' | 'all') => Promise<CacheSummary>
|
||||||
initDb: (
|
initDb: (
|
||||||
key: string
|
key: string,
|
||||||
|
accountRoot: string
|
||||||
) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }>
|
) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }>
|
||||||
|
discoverAccounts: (inputPath: string) => Promise<AccountDiscoveryResult>
|
||||||
getBootstrapCache: () => Promise<{
|
getBootstrapCache: () => Promise<{
|
||||||
self?: { wxid: string; nickname: string; avatar?: string; accountRoot: string }
|
self?: { wxid: string; nickname: string; avatar?: string; accountRoot: string }
|
||||||
contacts: Contact[]
|
contacts: Contact[]
|
||||||
@@ -239,14 +242,17 @@ declare global {
|
|||||||
) => Promise<SaveGeneratedReportResult>
|
) => Promise<SaveGeneratedReportResult>
|
||||||
deleteGeneratedReport: (reportId: string) => Promise<DeleteGeneratedReportResult>
|
deleteGeneratedReport: (reportId: string) => Promise<DeleteGeneratedReportResult>
|
||||||
revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }>
|
revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }>
|
||||||
getSavedDbKey: () => Promise<DatabaseKeyStorageResult>
|
getSavedDbKey: (accountRoot: string) => Promise<DatabaseKeyStorageResult>
|
||||||
getDatabaseKeyEnvironment: () => Promise<DatabaseKeyEnvironment>
|
getDatabaseKeyEnvironment: () => Promise<DatabaseKeyEnvironment>
|
||||||
readDatabaseKeyClipboard: () => Promise<{
|
readDatabaseKeyClipboard: () => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
value?: string
|
value?: string
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
autoGetDbKey: (options?: { save?: boolean }) => Promise<{
|
autoGetDbKey: (
|
||||||
|
accountRoot: string,
|
||||||
|
options?: { save?: boolean }
|
||||||
|
) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
key?: string
|
key?: string
|
||||||
error?: string
|
error?: string
|
||||||
@@ -290,9 +296,11 @@ declare global {
|
|||||||
request: TestImageDecryptionRequest
|
request: TestImageDecryptionRequest
|
||||||
) => Promise<ImageDecryptionTestResult>
|
) => Promise<ImageDecryptionTestResult>
|
||||||
clearImageKeyConfig: () => Promise<{ success: boolean; error?: string }>
|
clearImageKeyConfig: () => Promise<{ success: boolean; error?: string }>
|
||||||
pasteAndSaveDbKey: () => Promise<{ success: boolean; key?: string; error?: string }>
|
pasteAndSaveDbKey: (
|
||||||
saveDbKey: (key: string) => Promise<DatabaseKeyStorageResult>
|
accountRoot: string
|
||||||
clearSavedDbKey: () => Promise<{ success: boolean; error?: 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
|
onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => () => void
|
||||||
onDbKeyStatus: (callback: (payload: { message: string }) => void) => () => void
|
onDbKeyStatus: (callback: (payload: { message: string }) => void) => () => void
|
||||||
onImageKeyStatus: (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 { CacheSummary } from '../shared/cache'
|
||||||
import type { ExportRequest, ExportJobProgress } from '../shared/export'
|
import type { ExportRequest, ExportJobProgress } from '../shared/export'
|
||||||
import type { ImageDecoderSelectionResult, ImageDecoderStatus } from '../shared/image-decryption'
|
import type { ImageDecoderSelectionResult, ImageDecoderStatus } from '../shared/image-decryption'
|
||||||
|
import type { AccountDiscoveryResult } from '../shared/database-key'
|
||||||
|
|
||||||
// 渲染器的自定义 API
|
// 渲染器的自定义 API
|
||||||
const api = {
|
const api = {
|
||||||
@@ -40,7 +41,9 @@ const api = {
|
|||||||
getCacheSummary: (): Promise<CacheSummary> => ipcRenderer.invoke('cache:getSummary'),
|
getCacheSummary: (): Promise<CacheSummary> => ipcRenderer.invoke('cache:getSummary'),
|
||||||
clearCache: (scope: 'bootstrap' | 'electron' | 'all'): Promise<CacheSummary> =>
|
clearCache: (scope: 'bootstrap' | 'electron' | 'all'): Promise<CacheSummary> =>
|
||||||
ipcRenderer.invoke('cache:clear', scope),
|
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'),
|
getBootstrapCache: () => ipcRenderer.invoke('db:getBootstrapCache'),
|
||||||
getStartupCache: () => ipcRenderer.invoke('db:getStartupCache'),
|
getStartupCache: () => ipcRenderer.invoke('db:getStartupCache'),
|
||||||
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
|
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
|
||||||
@@ -98,10 +101,11 @@ const api = {
|
|||||||
deleteGeneratedReport: (reportId: string) =>
|
deleteGeneratedReport: (reportId: string) =>
|
||||||
ipcRenderer.invoke('report:deleteGenerated', reportId),
|
ipcRenderer.invoke('report:deleteGenerated', reportId),
|
||||||
revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath),
|
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'),
|
getDatabaseKeyEnvironment: () => ipcRenderer.invoke('key:getEnvironment'),
|
||||||
readDatabaseKeyClipboard: () => ipcRenderer.invoke('key:readClipboardDbKey'),
|
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 }) =>
|
autoGetImageKey: (options?: { save?: boolean }) =>
|
||||||
ipcRenderer.invoke('key:autoGetImageKey', options),
|
ipcRenderer.invoke('key:autoGetImageKey', options),
|
||||||
getImageKeyConfig: () => ipcRenderer.invoke('image:getConfig'),
|
getImageKeyConfig: () => ipcRenderer.invoke('image:getConfig'),
|
||||||
@@ -115,9 +119,11 @@ const api = {
|
|||||||
saveImageKeyConfig: (request) => ipcRenderer.invoke('image:saveConfig', request),
|
saveImageKeyConfig: (request) => ipcRenderer.invoke('image:saveConfig', request),
|
||||||
testImageDecryption: (request) => ipcRenderer.invoke('image:testConfig', request),
|
testImageDecryption: (request) => ipcRenderer.invoke('image:testConfig', request),
|
||||||
clearImageKeyConfig: () => ipcRenderer.invoke('image:clearConfig'),
|
clearImageKeyConfig: () => ipcRenderer.invoke('image:clearConfig'),
|
||||||
pasteAndSaveDbKey: () => ipcRenderer.invoke('key:pasteAndSaveDbKey'),
|
pasteAndSaveDbKey: (accountRoot: string) =>
|
||||||
saveDbKey: (key: string) => ipcRenderer.invoke('key:saveDbKey', key),
|
ipcRenderer.invoke('key:pasteAndSaveDbKey', accountRoot),
|
||||||
clearSavedDbKey: () => ipcRenderer.invoke('key:clearSavedDbKey'),
|
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) => {
|
onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => {
|
||||||
const listener = (
|
const listener = (
|
||||||
_event: Electron.IpcRendererEvent,
|
_event: Electron.IpcRendererEvent,
|
||||||
|
|||||||
+246
-18
@@ -24,6 +24,7 @@ import { FirstUseWelcome } from './components/FirstUseWelcome'
|
|||||||
import { ExportWorkspace } from './components/export/ExportWorkspace'
|
import { ExportWorkspace } from './components/export/ExportWorkspace'
|
||||||
import { AISearchWorkspace } from './components/search/AISearchWorkspace'
|
import { AISearchWorkspace } from './components/search/AISearchWorkspace'
|
||||||
import type { ExportJobProgress, ExportRequest, ExportTaskRecord } from '../../shared/export'
|
import type { ExportJobProgress, ExportRequest, ExportTaskRecord } from '../../shared/export'
|
||||||
|
import type { DatabaseKeyEnvironment, WechatAccountCandidate } from '../../shared/database-key'
|
||||||
import {
|
import {
|
||||||
getMessageIdentity,
|
getMessageIdentity,
|
||||||
mergeMessagePages,
|
mergeMessagePages,
|
||||||
@@ -32,6 +33,23 @@ import {
|
|||||||
|
|
||||||
const SIDEBAR_MIN_WIDTH = 260
|
const SIDEBAR_MIN_WIDTH = 260
|
||||||
const SIDEBAR_MAX_WIDTH = 380
|
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 {
|
function getDevelopmentDatabaseKey(): string {
|
||||||
if (!import.meta.env.DEV) return ''
|
if (!import.meta.env.DEV) return ''
|
||||||
@@ -202,6 +220,7 @@ function App(): React.ReactElement {
|
|||||||
const [selectedContact, setSelectedContact] = useState<Contact | null>(null)
|
const [selectedContact, setSelectedContact] = useState<Contact | null>(null)
|
||||||
const [messages, setMessages] = useState<Message[]>([])
|
const [messages, setMessages] = useState<Message[]>([])
|
||||||
const [isMessagesLoading, setIsMessagesLoading] = useState(false)
|
const [isMessagesLoading, setIsMessagesLoading] = useState(false)
|
||||||
|
const [messageHistoryStatus, setMessageHistoryStatus] = useState<'idle' | 'end' | 'error'>('idle')
|
||||||
const [filteredContacts, setFilteredContacts] = useState<Contact[]>([])
|
const [filteredContacts, setFilteredContacts] = useState<Contact[]>([])
|
||||||
const [contentFilter, setContentFilter] = useState('')
|
const [contentFilter, setContentFilter] = useState('')
|
||||||
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
|
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
|
||||||
@@ -209,10 +228,16 @@ function App(): React.ReactElement {
|
|||||||
const [dbKeyStatusKind, setDbKeyStatusKind] = useState<'normal' | 'success' | 'error'>('normal')
|
const [dbKeyStatusKind, setDbKeyStatusKind] = useState<'normal' | 'success' | 'error'>('normal')
|
||||||
const [showDbKey, setShowDbKey] = useState(false)
|
const [showDbKey, setShowDbKey] = useState(false)
|
||||||
const [dbRootInput, setDbRootInput] = useState('')
|
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 [showMacKeyFaq, setShowMacKeyFaq] = useState(false)
|
||||||
const [databaseConnectionMode, setDatabaseConnectionMode] = useState<DatabaseConnectionMode>(
|
const [databaseConnectionMode, setDatabaseConnectionMode] = useState<DatabaseConnectionMode>(
|
||||||
getDevelopmentDatabaseKey() ? 'manual' : 'automatic'
|
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 [activePage, setActivePage] = useState<AppPage>('archive')
|
||||||
const [archiveJumpTime, setArchiveJumpTime] = useState<number | null>(null)
|
const [archiveJumpTime, setArchiveJumpTime] = useState<number | null>(null)
|
||||||
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database')
|
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(() => {
|
React.useEffect(() => {
|
||||||
const loadAIConfig = async (): Promise<void> => {
|
const loadAIConfig = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@@ -533,13 +586,26 @@ function App(): React.ReactElement {
|
|||||||
if (active && settingsResult.settings.dbRoot) {
|
if (active && settingsResult.settings.dbRoot) {
|
||||||
setDbRootInput(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
|
const autoLoginEnabled = settingsResult.settings.autoLogin
|
||||||
// 开发环境允许使用 VITE_DB_KEY;生产安装包只能读取目标电脑自己的 safeStorage。
|
// 开发环境允许使用 VITE_DB_KEY;生产安装包只能读取目标电脑自己的 safeStorage。
|
||||||
const envKey = getDevelopmentDatabaseKey()
|
const envKey = getDevelopmentDatabaseKey()
|
||||||
// 生产环境以及未配置开发密钥时,读取上一次保存到 safeStorage 的密钥。
|
// 生产环境以及未配置开发密钥时,读取上一次保存到 safeStorage 的密钥。
|
||||||
let savedKey = ''
|
let savedKey = ''
|
||||||
if (!envKey) {
|
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
|
if (result.success && result.key) savedKey = result.key
|
||||||
}
|
}
|
||||||
const key = envKey || savedKey
|
const key = envKey || savedKey
|
||||||
@@ -570,7 +636,11 @@ function App(): React.ReactElement {
|
|||||||
try {
|
try {
|
||||||
const startupCacheReady = await loadStartupCache()
|
const startupCacheReady = await loadStartupCache()
|
||||||
setIsDatabaseConnecting(true)
|
setIsDatabaseConnecting(true)
|
||||||
const initPromise = window.api.initDb(key)
|
if (!startupAccountRoot) {
|
||||||
|
setBootState('login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const initPromise = window.api.initDb(key, startupAccountRoot)
|
||||||
if (startupCacheReady) {
|
if (startupCacheReady) {
|
||||||
setIsAuthenticated(true)
|
setIsAuthenticated(true)
|
||||||
setIsDatabaseConnected(false)
|
setIsDatabaseConnected(false)
|
||||||
@@ -655,13 +725,36 @@ function App(): React.ReactElement {
|
|||||||
void loadGeneratedReports()
|
void loadGeneratedReports()
|
||||||
}, [isAuthenticated, loadGeneratedReports])
|
}, [isAuthenticated, loadGeneratedReports])
|
||||||
|
|
||||||
const handleLogin = async (keyInput?: string): Promise<void> => {
|
const handleLogin = async (keyInput?: string, accountRootInput?: string): Promise<void> => {
|
||||||
const keyToUse = keyInput || dbKey
|
const keyToUse = keyInput || dbKey
|
||||||
if (!keyToUse) return
|
let accountRoot = accountRootInput || selectedAccount?.accountRoot
|
||||||
setBootState('connecting')
|
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)
|
setIsDatabaseConnecting(true)
|
||||||
// 持久化用户手动指定的微信聊天文件路径,供 db:init 读取 settings.dbRoot
|
// 持久化用户手动指定的微信聊天文件路径,供 db:init 读取 settings.dbRoot
|
||||||
const trimmedRoot = dbRootInput.trim()
|
const trimmedRoot = accountRoot
|
||||||
if (trimmedRoot) {
|
if (trimmedRoot) {
|
||||||
try {
|
try {
|
||||||
await window.api.setSettings({ dbRoot: trimmedRoot })
|
await window.api.setSettings({ dbRoot: trimmedRoot })
|
||||||
@@ -682,7 +775,12 @@ function App(): React.ReactElement {
|
|||||||
detail: '正在打开 WCDB 数据库',
|
detail: '正在打开 WCDB 数据库',
|
||||||
percent: 15
|
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
|
const success = typeof result === 'boolean' ? result : result.success
|
||||||
if (success) {
|
if (success) {
|
||||||
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
|
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
|
||||||
@@ -693,8 +791,9 @@ function App(): React.ReactElement {
|
|||||||
percent: 25
|
percent: 25
|
||||||
})
|
})
|
||||||
const hasBootstrap = await loadBootstrapCache()
|
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) => {
|
void window.api.getSettings().then((current) => {
|
||||||
if (!current.settings.autoLoginPreferenceSet) {
|
if (!current.settings.autoLoginPreferenceSet) {
|
||||||
void window.api.setSettings({ autoLogin: true })
|
void window.api.setSettings({ autoLogin: true })
|
||||||
@@ -734,17 +833,23 @@ function App(): React.ReactElement {
|
|||||||
}, 500)
|
}, 500)
|
||||||
} else {
|
} else {
|
||||||
const error = typeof result === 'boolean' ? '' : result.error
|
const error = typeof result === 'boolean' ? '' : result.error
|
||||||
|
setDbKeyStatus(error || '数据库连接失败,请检查密钥和数据目录后重试')
|
||||||
|
setDbKeyStatusKind('error')
|
||||||
|
if (databaseConnectionMode === 'automatic') setConnectionGuideStep(5)
|
||||||
setBootState('login')
|
setBootState('login')
|
||||||
setStartupProgress(null)
|
setStartupProgress(null)
|
||||||
alert(`Failed to open database.${error ? `\n\n${error}` : '\nCheck your key.'}`)
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
setDbKeyStatus(
|
||||||
|
error instanceof Error ? `数据库连接失败:${error.message}` : '数据库连接失败,请重试'
|
||||||
|
)
|
||||||
|
setDbKeyStatusKind('error')
|
||||||
|
if (databaseConnectionMode === 'automatic') setConnectionGuideStep(5)
|
||||||
setBootState('login')
|
setBootState('login')
|
||||||
setStartupProgress(null)
|
setStartupProgress(null)
|
||||||
alert('Error connecting to database')
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsDatabaseConnecting(false)
|
if (operationId === connectionOperationRef.current) setIsDatabaseConnecting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -864,31 +969,42 @@ function App(): React.ReactElement {
|
|||||||
|
|
||||||
const handleAutoGetDbKey = async (): Promise<void> => {
|
const handleAutoGetDbKey = async (): Promise<void> => {
|
||||||
if (isFetchingDbKey) return
|
if (isFetchingDbKey) return
|
||||||
|
const operationId = ++connectionOperationRef.current
|
||||||
|
setConnectionGuideStep(4)
|
||||||
setIsFetchingDbKey(true)
|
setIsFetchingDbKey(true)
|
||||||
setDbKeyStatus('正在准备获取密钥...')
|
setDbKeyStatus('正在准备获取密钥...')
|
||||||
setDbKeyStatusKind('normal')
|
setDbKeyStatusKind('normal')
|
||||||
setShowMacKeyFaq(false)
|
setShowMacKeyFaq(false)
|
||||||
try {
|
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) {
|
if (!result.success || !result.key) {
|
||||||
setShowMacKeyFaq(result.code === 'SCAN_FAILED')
|
setShowMacKeyFaq(result.code === 'SCAN_FAILED')
|
||||||
throw new Error(result.error || '获取密钥失败')
|
throw new Error(result.error || '获取密钥失败')
|
||||||
}
|
}
|
||||||
setDbKey(result.key)
|
setDbKey(result.key)
|
||||||
setDatabaseConnectionMode('manual')
|
setConnectionGuideStep(5)
|
||||||
setDbKeyStatus(result.saved ? '密钥已获取并安全保存' : result.warning || '密钥已获取')
|
setDbKeyStatus(result.saved ? '密钥已获取并安全保存' : result.warning || '密钥已获取')
|
||||||
setDbKeyStatusKind(result.saved ? 'success' : 'normal')
|
setDbKeyStatusKind(result.saved ? 'success' : 'normal')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (operationId !== connectionOperationRef.current) return
|
||||||
setDbKeyStatus(error instanceof Error ? error.message : String(error))
|
setDbKeyStatus(error instanceof Error ? error.message : String(error))
|
||||||
setDbKeyStatusKind('error')
|
setDbKeyStatusKind('error')
|
||||||
|
setConnectionGuideStep(3)
|
||||||
} finally {
|
} finally {
|
||||||
setIsFetchingDbKey(false)
|
if (operationId === connectionOperationRef.current) setIsFetchingDbKey(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePasteAndSaveDbKey = async (): Promise<void> => {
|
const handlePasteAndSaveDbKey = async (): Promise<void> => {
|
||||||
setShowMacKeyFaq(false)
|
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) {
|
if (result.success && result.key) {
|
||||||
setDbKey(result.key)
|
setDbKey(result.key)
|
||||||
setDatabaseConnectionMode('manual')
|
setDatabaseConnectionMode('manual')
|
||||||
@@ -902,7 +1018,8 @@ function App(): React.ReactElement {
|
|||||||
|
|
||||||
const handleClearSavedDbKey = async (): Promise<void> => {
|
const handleClearSavedDbKey = async (): Promise<void> => {
|
||||||
setShowMacKeyFaq(false)
|
setShowMacKeyFaq(false)
|
||||||
const result = await window.api.clearSavedDbKey()
|
if (!selectedAccount) return
|
||||||
|
const result = await window.api.clearSavedDbKey(selectedAccount.accountRoot)
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setDbKeyStatus(result.error || '清除密钥失败')
|
setDbKeyStatus(result.error || '清除密钥失败')
|
||||||
setDbKeyStatusKind('error')
|
setDbKeyStatusKind('error')
|
||||||
@@ -935,12 +1052,54 @@ function App(): React.ReactElement {
|
|||||||
setStartupProgress(null)
|
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> => {
|
const handleSelectContact = async (contact: Contact, forceLive = false): Promise<void> => {
|
||||||
setArchiveJumpTime(null)
|
setArchiveJumpTime(null)
|
||||||
setSelectedContact(contact)
|
setSelectedContact(contact)
|
||||||
selectedContactMd5Ref.current = contact.md5
|
selectedContactMd5Ref.current = contact.md5
|
||||||
currentGroupSnapshotRef.current = null
|
currentGroupSnapshotRef.current = null
|
||||||
setIsMessagesLoading(true)
|
setIsMessagesLoading(true)
|
||||||
|
setMessageHistoryStatus('idle')
|
||||||
const cachedPage = await window.api.getCachedMessagePage(contact.md5)
|
const cachedPage = await window.api.getCachedMessagePage(contact.md5)
|
||||||
const cachedMsgs = cachedPage.messages
|
const cachedMsgs = cachedPage.messages
|
||||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||||
@@ -1052,7 +1211,7 @@ function App(): React.ReactElement {
|
|||||||
|
|
||||||
const handleLoadOlderMessages = async (): Promise<void> => {
|
const handleLoadOlderMessages = async (): Promise<void> => {
|
||||||
const contact = selectedContact
|
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 (messagePrefetchRef.current) await messagePrefetchRef.current
|
||||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||||
const currentMessages = messagesRef.current
|
const currentMessages = messagesRef.current
|
||||||
@@ -1089,6 +1248,7 @@ function App(): React.ReactElement {
|
|||||||
limit: MESSAGE_PAGE_SIZE
|
limit: MESSAGE_PAGE_SIZE
|
||||||
})
|
})
|
||||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||||
|
setMessageHistoryStatus(olderMessages.length === 0 ? 'end' : 'idle')
|
||||||
messageHistoryRef.current = mergeMessagePages(olderMessages, historyMessages)
|
messageHistoryRef.current = mergeMessagePages(olderMessages, historyMessages)
|
||||||
setMessages((current) =>
|
setMessages((current) =>
|
||||||
applyGroupMemberMeta(
|
applyGroupMemberMeta(
|
||||||
@@ -1098,6 +1258,7 @@ function App(): React.ReactElement {
|
|||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[Messages] older page load failed:', error)
|
console.warn('[Messages] older page load failed:', error)
|
||||||
|
if (selectedContactMd5Ref.current === contact.md5) setMessageHistoryStatus('error')
|
||||||
} finally {
|
} finally {
|
||||||
if (selectedContactMd5Ref.current === contact.md5) setIsMessagesLoading(false)
|
if (selectedContactMd5Ref.current === contact.md5) setIsMessagesLoading(false)
|
||||||
}
|
}
|
||||||
@@ -1434,6 +1595,7 @@ function App(): React.ReactElement {
|
|||||||
contact={selectedContact}
|
contact={selectedContact}
|
||||||
messages={messages}
|
messages={messages}
|
||||||
isLoadingMessages={isMessagesLoading}
|
isLoadingMessages={isMessagesLoading}
|
||||||
|
messageHistoryStatus={messageHistoryStatus}
|
||||||
contentFilter={contentFilter}
|
contentFilter={contentFilter}
|
||||||
onContentFilterChange={setContentFilter}
|
onContentFilterChange={setContentFilter}
|
||||||
onRefresh={() => selectedContact && handleSelectContact(selectedContact, true)}
|
onRefresh={() => selectedContact && handleSelectContact(selectedContact, true)}
|
||||||
@@ -1561,6 +1723,7 @@ function App(): React.ReactElement {
|
|||||||
onNotice={setReportNotice}
|
onNotice={setReportNotice}
|
||||||
onOpenSettings={openSettings}
|
onOpenSettings={openSettings}
|
||||||
onAppearanceChange={handleAppearanceChange}
|
onAppearanceChange={handleAppearanceChange}
|
||||||
|
onSwitchAccount={handleSwitchAccount}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
case 'search':
|
case 'search':
|
||||||
@@ -1675,15 +1838,80 @@ function App(): React.ReactElement {
|
|||||||
dbRoot={dbRootInput}
|
dbRoot={dbRootInput}
|
||||||
showDbKey={showDbKey}
|
showDbKey={showDbKey}
|
||||||
isFetching={isFetchingDbKey}
|
isFetching={isFetchingDbKey}
|
||||||
|
isConnecting={isDatabaseConnecting}
|
||||||
|
guideStep={connectionGuideStep}
|
||||||
|
environment={databaseEnvironment}
|
||||||
|
accounts={discoveredAccounts}
|
||||||
|
selectedAccountId={selectedAccountId}
|
||||||
status={dbKeyStatus}
|
status={dbKeyStatus}
|
||||||
statusKind={dbKeyStatusKind}
|
statusKind={dbKeyStatusKind}
|
||||||
showMacKeyFaq={showMacKeyFaq}
|
showMacKeyFaq={showMacKeyFaq}
|
||||||
macKeyFaqUrl={MAC_KEY_FAQ_URL}
|
macKeyFaqUrl={MAC_KEY_FAQ_URL}
|
||||||
onModeChange={setDatabaseConnectionMode}
|
onModeChange={setDatabaseConnectionMode}
|
||||||
onDbKeyChange={setDbKey}
|
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)}
|
onToggleDbKey={() => setShowDbKey((visible) => !visible)}
|
||||||
onAutoGetKey={handleAutoGetDbKey}
|
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()}
|
onManualConnect={() => handleLogin()}
|
||||||
onPasteKey={handlePasteAndSaveDbKey}
|
onPasteKey={handlePasteAndSaveDbKey}
|
||||||
onClearKey={handleClearSavedDbKey}
|
onClearKey={handleClearSavedDbKey}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface ChatWindowProps {
|
|||||||
contact: Contact | null
|
contact: Contact | null
|
||||||
messages: Message[]
|
messages: Message[]
|
||||||
isLoadingMessages?: boolean
|
isLoadingMessages?: boolean
|
||||||
|
messageHistoryStatus?: 'idle' | 'end' | 'error'
|
||||||
contentFilter?: string
|
contentFilter?: string
|
||||||
onContentFilterChange?: (keyword: string) => void
|
onContentFilterChange?: (keyword: string) => void
|
||||||
onRefresh?: () => void
|
onRefresh?: () => void
|
||||||
@@ -25,6 +26,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
contact,
|
contact,
|
||||||
messages,
|
messages,
|
||||||
isLoadingMessages,
|
isLoadingMessages,
|
||||||
|
messageHistoryStatus,
|
||||||
contentFilter,
|
contentFilter,
|
||||||
onContentFilterChange,
|
onContentFilterChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
@@ -205,6 +207,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
messages={filteredMessages}
|
messages={filteredMessages}
|
||||||
hiddenMessageCount={0}
|
hiddenMessageCount={0}
|
||||||
isLoadingMessages={isLoadingMessages}
|
isLoadingMessages={isLoadingMessages}
|
||||||
|
messageHistoryStatus={messageHistoryStatus}
|
||||||
isGroupChat={isGroupChat}
|
isGroupChat={isGroupChat}
|
||||||
showAvatar={showAvatar}
|
showAvatar={showAvatar}
|
||||||
listRef={messageListRef}
|
listRef={messageListRef}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import type { DatabaseKeyEnvironment, WechatAccountCandidate } from '../../../shared/database-key'
|
||||||
|
|
||||||
const GUIDE_URL =
|
const GUIDE_URL =
|
||||||
'https://github.com/Wxw-Gu/WechatExplorer/blob/main/docs/user-guide/getting-started.md'
|
'https://github.com/Wxw-Gu/WechatExplorer/blob/main/docs/user-guide/getting-started.md'
|
||||||
@@ -13,6 +14,11 @@ interface DatabaseConnectionPageProps {
|
|||||||
dbRoot: string
|
dbRoot: string
|
||||||
showDbKey: boolean
|
showDbKey: boolean
|
||||||
isFetching: boolean
|
isFetching: boolean
|
||||||
|
isConnecting: boolean
|
||||||
|
guideStep: 1 | 2 | 3 | 4 | 5 | 6
|
||||||
|
environment?: DatabaseKeyEnvironment
|
||||||
|
accounts: WechatAccountCandidate[]
|
||||||
|
selectedAccountId: string
|
||||||
status: string
|
status: string
|
||||||
statusKind: DatabaseConnectionStatusKind
|
statusKind: DatabaseConnectionStatusKind
|
||||||
showMacKeyFaq: boolean
|
showMacKeyFaq: boolean
|
||||||
@@ -20,8 +26,16 @@ interface DatabaseConnectionPageProps {
|
|||||||
onModeChange: (mode: DatabaseConnectionMode) => void
|
onModeChange: (mode: DatabaseConnectionMode) => void
|
||||||
onDbKeyChange: (value: string) => void
|
onDbKeyChange: (value: string) => void
|
||||||
onDbRootChange: (value: string) => void
|
onDbRootChange: (value: string) => void
|
||||||
|
onSelectAccount: (account: WechatAccountCandidate) => void
|
||||||
|
onSelectDbRoot: () => void
|
||||||
onToggleDbKey: () => void
|
onToggleDbKey: () => void
|
||||||
onAutoGetKey: () => void
|
onAutoGetKey: () => void
|
||||||
|
onRefreshEnvironment: () => void
|
||||||
|
onGuideNext: () => void
|
||||||
|
onGuideBack: () => void
|
||||||
|
onGuideCancel: () => void
|
||||||
|
onValidateConnection: () => void
|
||||||
|
onCopyDiagnostics: () => void
|
||||||
onManualConnect: () => void
|
onManualConnect: () => void
|
||||||
onPasteKey: () => void
|
onPasteKey: () => void
|
||||||
onClearKey: () => void
|
onClearKey: () => void
|
||||||
@@ -92,6 +106,11 @@ export function DatabaseConnectionPage({
|
|||||||
dbRoot,
|
dbRoot,
|
||||||
showDbKey,
|
showDbKey,
|
||||||
isFetching,
|
isFetching,
|
||||||
|
isConnecting,
|
||||||
|
guideStep,
|
||||||
|
environment,
|
||||||
|
accounts = [],
|
||||||
|
selectedAccountId = '',
|
||||||
status,
|
status,
|
||||||
statusKind,
|
statusKind,
|
||||||
showMacKeyFaq,
|
showMacKeyFaq,
|
||||||
@@ -99,8 +118,16 @@ export function DatabaseConnectionPage({
|
|||||||
onModeChange,
|
onModeChange,
|
||||||
onDbKeyChange,
|
onDbKeyChange,
|
||||||
onDbRootChange,
|
onDbRootChange,
|
||||||
|
onSelectAccount,
|
||||||
|
onSelectDbRoot,
|
||||||
onToggleDbKey,
|
onToggleDbKey,
|
||||||
onAutoGetKey,
|
onAutoGetKey,
|
||||||
|
onRefreshEnvironment,
|
||||||
|
onGuideNext,
|
||||||
|
onGuideBack,
|
||||||
|
onGuideCancel,
|
||||||
|
onValidateConnection,
|
||||||
|
onCopyDiagnostics,
|
||||||
onManualConnect,
|
onManualConnect,
|
||||||
onPasteKey,
|
onPasteKey,
|
||||||
onClearKey
|
onClearKey
|
||||||
@@ -146,27 +173,27 @@ export function DatabaseConnectionPage({
|
|||||||
<div className="database-login-start">
|
<div className="database-login-start">
|
||||||
<p className="database-login-eyebrow">第一次使用</p>
|
<p className="database-login-eyebrow">第一次使用</p>
|
||||||
<h2>开始连接微信</h2>
|
<h2>开始连接微信</h2>
|
||||||
<p>跟着下面 3 步操作,通常几分钟即可完成连接。</p>
|
<p>按顺序检查环境、准备连接组件并验证数据库,通常几分钟即可完成。</p>
|
||||||
<ol>
|
<ol>
|
||||||
<li>
|
<li>
|
||||||
<span>1</span>
|
<span>1</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>确认微信数据目录</strong>
|
<strong>检查本机环境</strong>
|
||||||
<small>没有自动找到时,可在设置中手动选择</small>
|
<small>确认微信版本、数据目录和运行状态</small>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span>2</span>
|
<span>2</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>让微信停在登录页面</strong>
|
<strong>准备连接组件</strong>
|
||||||
<small>不要在获取密钥前完成登录</small>
|
<small>页面会按当前系统给出对应步骤</small>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span>3</span>
|
<span>3</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>点击自动获取密钥</strong>
|
<strong>登录并验证连接</strong>
|
||||||
<small>提示可以登录后,再回到微信完成登录</small>
|
<small>验证通过后进入主界面</small>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
@@ -202,6 +229,11 @@ export function DatabaseConnectionPage({
|
|||||||
|
|
||||||
{mode === 'automatic' ? (
|
{mode === 'automatic' ? (
|
||||||
<div className="database-login-auto" role="tabpanel">
|
<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-card ${statusKind}`}>
|
||||||
<div className="database-login-state-heading">
|
<div className="database-login-state-heading">
|
||||||
<span className="database-login-state-icon">
|
<span className="database-login-state-icon">
|
||||||
@@ -209,19 +241,46 @@ export function DatabaseConnectionPage({
|
|||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>
|
<strong>
|
||||||
{statusKind === 'error' ? '未能获取数据库密钥' : '已准备检测微信数据库'}
|
{statusKind === 'error'
|
||||||
|
? '当前步骤未完成'
|
||||||
|
: [
|
||||||
|
'检查本机环境',
|
||||||
|
'让微信停在登录页面',
|
||||||
|
'确认开始准备',
|
||||||
|
`正在完成 ${isMac ? 'macOS' : 'Windows'} 授权`,
|
||||||
|
'现在可以登录微信',
|
||||||
|
'验证数据库连接'
|
||||||
|
][guideStep - 1]}
|
||||||
</strong>
|
</strong>
|
||||||
<p>
|
<p>
|
||||||
{statusKind === 'error'
|
{statusKind === 'error'
|
||||||
? status
|
? status
|
||||||
: status || '请保持微信客户端正在运行,系统将尝试安全获取数据库密钥。'}
|
: status ||
|
||||||
|
[
|
||||||
|
'确认下方检测结果;没有找到目录时可以手动选择。',
|
||||||
|
'请退出当前微信账号,让微信停留在登录页面,然后点击“我已准备好”。',
|
||||||
|
'开始后请按页面提示完成系统授权。',
|
||||||
|
'正在准备连接组件,请不要关闭微信或 WechatExplorer。',
|
||||||
|
'请回到微信完成登录,登录成功后再回来验证。',
|
||||||
|
'正在验证密钥和本地数据库,请稍候。'
|
||||||
|
][guideStep - 1]}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{guideStep === 1 && (
|
||||||
|
<>
|
||||||
<dl className="database-login-diagnostics">
|
<dl className="database-login-diagnostics">
|
||||||
<div>
|
<div>
|
||||||
<dt>微信客户端</dt>
|
<dt>操作系统</dt>
|
||||||
<dd>{isFetching ? '正在检测' : '等待检测'}</dd>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<dt>
|
<dt>
|
||||||
@@ -244,22 +303,144 @@ export function DatabaseConnectionPage({
|
|||||||
{dbRoot || defaultPath}
|
{dbRoot || defaultPath}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="database-login-path-select"
|
||||||
|
onClick={onSelectDbRoot}
|
||||||
|
disabled={isFetching || isConnecting}
|
||||||
|
>
|
||||||
|
选择目录
|
||||||
|
</button>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>数据库状态</dt>
|
<dt>微信状态</dt>
|
||||||
<dd>{statusKind === 'error' ? '无法连接' : '准备连接'}</dd>
|
<dd>{environment?.wechatRunning ? '运行中' : '未检测到'}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</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>
|
</div>
|
||||||
|
{guideStep === 1 && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="database-login-primary"
|
className="database-login-primary"
|
||||||
onClick={onAutoGetKey}
|
onClick={onGuideNext}
|
||||||
disabled={isFetching}
|
disabled={!selectedAccountId}
|
||||||
>
|
>
|
||||||
{isFetching ? '正在获取密钥…' : statusKind === 'error' ? '重新检测' : '开始获取'}
|
检查完成,继续
|
||||||
</button>
|
</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">
|
<p className="database-login-platform-note">
|
||||||
{isMac ? (
|
{isMac ? (
|
||||||
<>
|
<>
|
||||||
@@ -311,8 +492,10 @@ export function DatabaseConnectionPage({
|
|||||||
微信数据目录
|
微信数据目录
|
||||||
<StoragePathHelp />
|
<StoragePathHelp />
|
||||||
</label>
|
</label>
|
||||||
|
<div className="database-login-root-control">
|
||||||
<input
|
<input
|
||||||
id="database-login-root"
|
id="database-login-root"
|
||||||
|
aria-label="微信数据目录"
|
||||||
value={dbRoot}
|
value={dbRoot}
|
||||||
onChange={(event) => onDbRootChange(event.target.value)}
|
onChange={(event) => onDbRootChange(event.target.value)}
|
||||||
placeholder={defaultPath}
|
placeholder={defaultPath}
|
||||||
@@ -320,6 +503,10 @@ export function DatabaseConnectionPage({
|
|||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
onFocus={(event) => event.currentTarget.select()}
|
onFocus={(event) => event.currentTarget.select()}
|
||||||
/>
|
/>
|
||||||
|
<button type="button" onClick={onSelectDbRoot} disabled={isConnecting}>
|
||||||
|
选择目录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{status && <div className={`database-login-message ${statusKind}`}>{status}</div>}
|
{status && <div className={`database-login-message ${statusKind}`}>{status}</div>}
|
||||||
@@ -327,11 +514,25 @@ export function DatabaseConnectionPage({
|
|||||||
type="button"
|
type="button"
|
||||||
className="database-login-primary"
|
className="database-login-primary"
|
||||||
onClick={onManualConnect}
|
onClick={onManualConnect}
|
||||||
disabled={!keyIsValid}
|
disabled={!keyIsValid || isConnecting}
|
||||||
>
|
>
|
||||||
连接数据库
|
{isConnecting ? '正在连接…' : '连接数据库'}
|
||||||
</button>
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface MessageListProps {
|
|||||||
messages: Message[]
|
messages: Message[]
|
||||||
hiddenMessageCount: number
|
hiddenMessageCount: number
|
||||||
isLoadingMessages?: boolean
|
isLoadingMessages?: boolean
|
||||||
|
messageHistoryStatus?: 'idle' | 'end' | 'error'
|
||||||
isGroupChat: boolean
|
isGroupChat: boolean
|
||||||
showAvatar: boolean
|
showAvatar: boolean
|
||||||
listRef: React.RefObject<HTMLDivElement | null>
|
listRef: React.RefObject<HTMLDivElement | null>
|
||||||
@@ -24,6 +25,7 @@ export function MessageList({
|
|||||||
messages,
|
messages,
|
||||||
hiddenMessageCount,
|
hiddenMessageCount,
|
||||||
isLoadingMessages,
|
isLoadingMessages,
|
||||||
|
messageHistoryStatus,
|
||||||
isGroupChat,
|
isGroupChat,
|
||||||
showAvatar,
|
showAvatar,
|
||||||
listRef,
|
listRef,
|
||||||
@@ -119,6 +121,18 @@ export function MessageList({
|
|||||||
return (
|
return (
|
||||||
<div className="message-list wechat-message-list" ref={listRef} onScroll={handleScroll}>
|
<div className="message-list wechat-message-list" ref={listRef} onScroll={handleScroll}>
|
||||||
{isLoadingMessages && <div className="message-loading-pill">正在加载聊天记录...</div>}
|
{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 && (
|
{hiddenMessageCount > 0 && (
|
||||||
<div className="wechat-system-message-row">
|
<div className="wechat-system-message-row">
|
||||||
<div className="wechat-system-message">
|
<div className="wechat-system-message">
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function ExportWorkspace({
|
|||||||
const [includeAvatars, setIncludeAvatars] = useState(true)
|
const [includeAvatars, setIncludeAvatars] = useState(true)
|
||||||
const [preferOriginal, setPreferOriginal] = useState(true)
|
const [preferOriginal, setPreferOriginal] = useState(true)
|
||||||
const [fallbackThumbnail, setFallbackThumbnail] = useState(true)
|
const [fallbackThumbnail, setFallbackThumbnail] = useState(true)
|
||||||
const [keepMissing, setKeepMissing] = useState(false)
|
const [keepMissing, setKeepMissing] = useState(true)
|
||||||
const [format, setFormat] = useState<ExportFormat>('csv')
|
const [format, setFormat] = useState<ExportFormat>('csv')
|
||||||
const [zip, setZip] = useState(false)
|
const [zip, setZip] = useState(false)
|
||||||
const [fileName, setFileName] = useState('')
|
const [fileName, setFileName] = useState('')
|
||||||
@@ -158,6 +158,8 @@ export function ExportWorkspace({
|
|||||||
|
|
||||||
const handleStart = async (): Promise<void> => {
|
const handleStart = async (): Promise<void> => {
|
||||||
if (!activeContact || status === 'running') return
|
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()}`
|
const nextJobId = `export-${Date.now()}`
|
||||||
setJobId(nextJobId)
|
setJobId(nextJobId)
|
||||||
setProgress(null)
|
setProgress(null)
|
||||||
@@ -204,6 +206,9 @@ export function ExportWorkspace({
|
|||||||
: undefined,
|
: undefined,
|
||||||
kinds: Array.from(selectedKinds) as ExportMessageKind[],
|
kinds: Array.from(selectedKinds) as ExportMessageKind[],
|
||||||
includeMedia,
|
includeMedia,
|
||||||
|
preferOriginal,
|
||||||
|
fallbackThumbnail,
|
||||||
|
keepMissing,
|
||||||
includeAvatars,
|
includeAvatars,
|
||||||
avatarUrls: exportAvatarUrls,
|
avatarUrls: exportAvatarUrls,
|
||||||
nameMode,
|
nameMode,
|
||||||
@@ -303,20 +308,39 @@ export function ExportWorkspace({
|
|||||||
<h3>导出格式</h3>
|
<h3>导出格式</h3>
|
||||||
<div className="export-format-grid">
|
<div className="export-format-grid">
|
||||||
{formatOrder.map((value) => (
|
{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>
|
<strong>{formatLabels[value].label}</strong>
|
||||||
{formatLabels[value].hint && <small>{formatLabels[value].hint}</small>}
|
{formatLabels[value].hint && <small>{formatLabels[value].hint}</small>}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="export-helper-text">CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。</p>
|
<p className="export-helper-text">
|
||||||
|
CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。
|
||||||
|
</p>
|
||||||
{format === 'html' && (
|
{format === 'html' && (
|
||||||
<div className="export-html-options">
|
<div className="export-html-options">
|
||||||
<label>
|
<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>
|
||||||
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -383,19 +407,13 @@ export function ExportWorkspace({
|
|||||||
<h3>消息内容</h3>
|
<h3>消息内容</h3>
|
||||||
<div className="export-kind-grid">
|
<div className="export-kind-grid">
|
||||||
{messageKinds.map(([value, label]) => (
|
{messageKinds.map(([value, label]) => (
|
||||||
<label key={value} className={`export-check-row ${value === 'video' ? 'unsupported' : ''}`}>
|
<label key={value} className="export-check-row">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={value !== 'video' && selectedKinds.has(value)}
|
checked={selectedKinds.has(value)}
|
||||||
disabled={value === 'video'}
|
|
||||||
onChange={() => toggleKind(value)}
|
onChange={() => toggleKind(value)}
|
||||||
/>
|
/>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
{value === 'video' && (
|
|
||||||
<span className="export-unsupported-hint" title="当前版本暂不支持视频导出" aria-label="当前版本暂不支持视频导出">
|
|
||||||
!
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -429,7 +447,9 @@ export function ExportWorkspace({
|
|||||||
onChange={(event) => setIncludeMedia(event.target.checked)}
|
onChange={(event) => setIncludeMedia(event.target.checked)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className={`export-media-options ${includeMedia && format === 'html' ? '' : 'disabled'}`}>
|
<div
|
||||||
|
className={`export-media-options ${includeMedia && format === 'html' ? '' : 'disabled'}`}
|
||||||
|
>
|
||||||
<label className="export-check-row">
|
<label className="export-check-row">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -458,7 +478,9 @@ export function ExportWorkspace({
|
|||||||
<span>媒体缺失时保留占位说明</span>
|
<span>媒体缺失时保留占位说明</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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">
|
<div className="export-resource-statuses">
|
||||||
<span>图片解密:已就绪</span>
|
<span>图片解密:已就绪</span>
|
||||||
<span>视频资源:可用</span>
|
<span>视频资源:可用</span>
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ export function SettingsWorkspace({
|
|||||||
onAIRuntimeChange,
|
onAIRuntimeChange,
|
||||||
onNotice,
|
onNotice,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
onAppearanceChange
|
onAppearanceChange,
|
||||||
|
onSwitchAccount
|
||||||
}: {
|
}: {
|
||||||
selectedCategory: SettingsCategoryId
|
selectedCategory: SettingsCategoryId
|
||||||
onCategoryChange: (id: SettingsCategoryId) => void
|
onCategoryChange: (id: SettingsCategoryId) => void
|
||||||
@@ -47,7 +48,13 @@ export function SettingsWorkspace({
|
|||||||
onAIRuntimeChange: (config: AIRuntimeModelConfig) => void
|
onAIRuntimeChange: (config: AIRuntimeModelConfig) => void
|
||||||
onNotice: (message: string) => void
|
onNotice: (message: string) => void
|
||||||
onOpenSettings: () => 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 {
|
}): React.ReactElement {
|
||||||
const renderSelectedPage = (): React.ReactElement => {
|
const renderSelectedPage = (): React.ReactElement => {
|
||||||
switch (selectedCategory) {
|
switch (selectedCategory) {
|
||||||
@@ -59,6 +66,7 @@ export function SettingsWorkspace({
|
|||||||
dbConnecting={dbConnecting}
|
dbConnecting={dbConnecting}
|
||||||
selfInfo={selfInfo}
|
selfInfo={selfInfo}
|
||||||
onNotice={onNotice}
|
onNotice={onNotice}
|
||||||
|
onSwitchAccount={onSwitchAccount}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
case 'database-key':
|
case 'database-key':
|
||||||
@@ -87,9 +95,7 @@ export function SettingsWorkspace({
|
|||||||
case 'cache-cleanup':
|
case 'cache-cleanup':
|
||||||
return <CacheCleanupPage onNotice={onNotice} />
|
return <CacheCleanupPage onNotice={onNotice} />
|
||||||
case 'appearance':
|
case 'appearance':
|
||||||
return (
|
return <AppearancePage onNotice={onNotice} onAppearanceChange={onAppearanceChange} />
|
||||||
<AppearancePage onNotice={onNotice} onAppearanceChange={onAppearanceChange} />
|
|
||||||
)
|
|
||||||
case 'about':
|
case 'about':
|
||||||
return <AboutPage onNotice={onNotice} />
|
return <AboutPage onNotice={onNotice} />
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ export function AccountOverview({
|
|||||||
isChecking,
|
isChecking,
|
||||||
onCheck,
|
onCheck,
|
||||||
onOpenDirectory,
|
onOpenDirectory,
|
||||||
onCopyDirectory
|
onCopyDirectory,
|
||||||
|
onSwitchAccount
|
||||||
}: {
|
}: {
|
||||||
selfInfo: SettingsSelfInfo | null
|
selfInfo: SettingsSelfInfo | null
|
||||||
connectionStatus: ConnectionOverviewStatus
|
connectionStatus: ConnectionOverviewStatus
|
||||||
@@ -34,6 +35,7 @@ export function AccountOverview({
|
|||||||
onCheck: () => void
|
onCheck: () => void
|
||||||
onOpenDirectory: () => void
|
onOpenDirectory: () => void
|
||||||
onCopyDirectory: () => void
|
onCopyDirectory: () => void
|
||||||
|
onSwitchAccount: () => void
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const accountRoot = selfInfo?.accountRoot || ''
|
const accountRoot = selfInfo?.accountRoot || ''
|
||||||
return (
|
return (
|
||||||
@@ -83,6 +85,9 @@ export function AccountOverview({
|
|||||||
>
|
>
|
||||||
打开账号目录
|
打开账号目录
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="api-secondary-button" onClick={onSwitchAccount}>
|
||||||
|
切换账号
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="settings-account-root">
|
<div className="settings-account-root">
|
||||||
|
|||||||
@@ -36,14 +36,14 @@ export function useDatabaseKeyController({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const refreshStorage = useCallback(async (): Promise<void> => {
|
const refreshStorage = useCallback(async (): Promise<void> => {
|
||||||
const result = await window.api.getSavedDbKey()
|
const result = await window.api.getSavedDbKey(selfInfo?.accountRoot || '')
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'STORAGE_LOADED',
|
type: 'STORAGE_LOADED',
|
||||||
saved: result.saved,
|
saved: result.saved,
|
||||||
encryptionAvailable: result.encryptionAvailable,
|
encryptionAvailable: result.encryptionAvailable,
|
||||||
error: result.success ? undefined : result.error
|
error: result.success ? undefined : result.error
|
||||||
})
|
})
|
||||||
}, [])
|
}, [selfInfo?.accountRoot])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void Promise.all([refreshStorage(), refreshEnvironment()])
|
void Promise.all([refreshStorage(), refreshEnvironment()])
|
||||||
@@ -109,7 +109,8 @@ export function useDatabaseKeyController({
|
|||||||
const saveKey = useCallback(async (): Promise<void> => {
|
const saveKey = useCallback(async (): Promise<void> => {
|
||||||
if (state.status !== 'valid') return
|
if (state.status !== 'valid') return
|
||||||
dispatch({ type: 'SAVE_START' })
|
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) {
|
if (!saved.success || !saved.key) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'SAVE_ERROR',
|
type: 'SAVE_ERROR',
|
||||||
@@ -117,12 +118,12 @@ export function useDatabaseKeyController({
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const stored = await window.api.getSavedDbKey()
|
const stored = await window.api.getSavedDbKey(accountRoot)
|
||||||
if (!stored.success || !stored.saved || !stored.key) {
|
if (!stored.success || !stored.saved || !stored.key) {
|
||||||
dispatch({ type: 'SAVE_ERROR', error: '无法确认密钥保存状态' })
|
dispatch({ type: 'SAVE_ERROR', error: '无法确认密钥保存状态' })
|
||||||
return
|
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
|
const connected = typeof initialized === 'boolean' ? initialized : initialized.success
|
||||||
onDbKeyChange(stored.key)
|
onDbKeyChange(stored.key)
|
||||||
onDatabaseConnectionChange(connected)
|
onDatabaseConnectionChange(connected)
|
||||||
@@ -148,13 +149,14 @@ export function useDatabaseKeyController({
|
|||||||
onNotice,
|
onNotice,
|
||||||
onSelfInfoChange,
|
onSelfInfoChange,
|
||||||
refreshEnvironment,
|
refreshEnvironment,
|
||||||
|
selfInfo?.accountRoot,
|
||||||
state.status
|
state.status
|
||||||
])
|
])
|
||||||
|
|
||||||
const autoDetectKey = useCallback(async (): Promise<void> => {
|
const autoDetectKey = useCallback(async (): Promise<void> => {
|
||||||
dispatch({ type: 'AUTO_START' })
|
dispatch({ type: 'AUTO_START' })
|
||||||
await refreshEnvironment()
|
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) {
|
if (!result.success || !result.key) {
|
||||||
dispatch({ type: 'AUTO_ERROR', error: result.error || '暂未找到有效密钥' })
|
dispatch({ type: 'AUTO_ERROR', error: result.error || '暂未找到有效密钥' })
|
||||||
return
|
return
|
||||||
@@ -162,11 +164,11 @@ export function useDatabaseKeyController({
|
|||||||
onDbKeyChange(result.key)
|
onDbKeyChange(result.key)
|
||||||
dispatch({ type: 'AUTO_SUCCESS' })
|
dispatch({ type: 'AUTO_SUCCESS' })
|
||||||
await runValidation(result.key)
|
await runValidation(result.key)
|
||||||
}, [onDbKeyChange, refreshEnvironment, runValidation])
|
}, [onDbKeyChange, refreshEnvironment, runValidation, selfInfo?.accountRoot])
|
||||||
|
|
||||||
const clearSavedKey = useCallback(async (): Promise<void> => {
|
const clearSavedKey = useCallback(async (): Promise<void> => {
|
||||||
dispatch({ type: 'CLEAR_START' })
|
dispatch({ type: 'CLEAR_START' })
|
||||||
const result = await window.api.clearSavedDbKey()
|
const result = await window.api.clearSavedDbKey(selfInfo?.accountRoot || '')
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
dispatch({ type: 'CLEAR_ERROR', error: '清除密钥失败' })
|
dispatch({ type: 'CLEAR_ERROR', error: '清除密钥失败' })
|
||||||
return
|
return
|
||||||
@@ -187,7 +189,8 @@ export function useDatabaseKeyController({
|
|||||||
onFilteredContactsChange,
|
onFilteredContactsChange,
|
||||||
onNotice,
|
onNotice,
|
||||||
onSelfInfoChange,
|
onSelfInfoChange,
|
||||||
refreshEnvironment
|
refreshEnvironment,
|
||||||
|
selfInfo?.accountRoot
|
||||||
])
|
])
|
||||||
|
|
||||||
const returnToLogin = useCallback(async (): Promise<void> => {
|
const returnToLogin = useCallback(async (): Promise<void> => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { LocalPrivacyNotice } from '../account-database/LocalPrivacyNotice'
|
|||||||
import { useAccountDatabaseController } from '../account-database/useAccountDatabaseController'
|
import { useAccountDatabaseController } from '../account-database/useAccountDatabaseController'
|
||||||
import type { ConnectionOverviewStatus } from '../account-database/types'
|
import type { ConnectionOverviewStatus } from '../account-database/types'
|
||||||
import type { SettingsSelfInfo } from '../model/types'
|
import type { SettingsSelfInfo } from '../model/types'
|
||||||
|
import type { WechatAccountCandidate } from '../../../../../shared/database-key'
|
||||||
|
|
||||||
const STATUS_LABELS: Record<ConnectionOverviewStatus, string> = {
|
const STATUS_LABELS: Record<ConnectionOverviewStatus, string> = {
|
||||||
checking: '正在检测',
|
checking: '正在检测',
|
||||||
@@ -19,13 +20,15 @@ export function AccountDatabasePage({
|
|||||||
dbReady,
|
dbReady,
|
||||||
dbConnecting = false,
|
dbConnecting = false,
|
||||||
selfInfo,
|
selfInfo,
|
||||||
onNotice
|
onNotice,
|
||||||
|
onSwitchAccount
|
||||||
}: {
|
}: {
|
||||||
dbKey: string
|
dbKey: string
|
||||||
dbReady: boolean
|
dbReady: boolean
|
||||||
dbConnecting?: boolean
|
dbConnecting?: boolean
|
||||||
selfInfo: SettingsSelfInfo | null
|
selfInfo: SettingsSelfInfo | null
|
||||||
onNotice: (message: string) => void
|
onNotice: (message: string) => void
|
||||||
|
onSwitchAccount: (account: WechatAccountCandidate) => Promise<void>
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const controller = useAccountDatabaseController({
|
const controller = useAccountDatabaseController({
|
||||||
dbKey,
|
dbKey,
|
||||||
@@ -35,6 +38,8 @@ export function AccountDatabasePage({
|
|||||||
onNotice
|
onNotice
|
||||||
})
|
})
|
||||||
const [autoLogin, setAutoLogin] = useState(false)
|
const [autoLogin, setAutoLogin] = useState(false)
|
||||||
|
const [switching, setSwitching] = useState(false)
|
||||||
|
const [accounts, setAccounts] = useState<WechatAccountCandidate[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
@@ -56,6 +61,18 @@ export function AccountDatabasePage({
|
|||||||
onNotice(checked ? '已开启启动时自动连接' : '已关闭启动时自动连接')
|
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 (
|
return (
|
||||||
<div className="settings-page">
|
<div className="settings-page">
|
||||||
<header className="settings-page-header">
|
<header className="settings-page-header">
|
||||||
@@ -79,7 +96,45 @@ export function AccountDatabasePage({
|
|||||||
onCheck={() => void controller.testConnection()}
|
onCheck={() => void controller.testConnection()}
|
||||||
onOpenDirectory={() => void controller.openAccountDirectory()}
|
onOpenDirectory={() => void controller.openAccountDirectory()}
|
||||||
onCopyDirectory={() => void controller.copyAccountDirectory()}
|
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>
|
<h2 className="settings-section-heading">连接健康检查</h2>
|
||||||
<ConnectionHealthSection
|
<ConnectionHealthSection
|
||||||
diagnostics={controller.diagnostics}
|
diagnostics={controller.diagnostics}
|
||||||
|
|||||||
@@ -1185,7 +1185,7 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 48px clamp(40px, 7vw, 96px);
|
padding: 28px clamp(40px, 7vw, 96px);
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1195,7 +1195,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.database-login-start {
|
.database-login-start {
|
||||||
margin-bottom: 22px;
|
margin-bottom: 14px;
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -1285,7 +1285,7 @@
|
|||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
margin-bottom: 28px;
|
margin-bottom: 18px;
|
||||||
border: 1px solid var(--login-border);
|
border: 1px solid var(--login-border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #e9eeeb;
|
background: #e9eeeb;
|
||||||
@@ -1410,6 +1410,58 @@
|
|||||||
text-align: right;
|
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 {
|
.database-login-path-input-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: block;
|
display: block;
|
||||||
@@ -1596,6 +1648,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.database-login-field > input,
|
.database-login-field > input,
|
||||||
|
.database-login-root-control,
|
||||||
.database-login-key-input {
|
.database-login-key-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
@@ -1604,6 +1657,37 @@
|
|||||||
background: #fff;
|
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 {
|
.database-login-field > input {
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
color: var(--login-text);
|
color: var(--login-text);
|
||||||
@@ -1617,6 +1701,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.database-login-key-input:focus-within,
|
.database-login-key-input:focus-within,
|
||||||
|
.database-login-root-control:focus-within,
|
||||||
.database-login-field > input:focus {
|
.database-login-field > input:focus {
|
||||||
border-color: var(--login-primary);
|
border-color: var(--login-primary);
|
||||||
box-shadow: 0 0 0 3px rgba(36, 122, 99, 0.1);
|
box-shadow: 0 0 0 3px rgba(36, 122, 99, 0.1);
|
||||||
@@ -1969,3 +2054,70 @@
|
|||||||
pointer-events: none;
|
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
|
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 {
|
export interface DatabaseKeyEnvironment {
|
||||||
platform: NodeJS.Platform
|
platform: NodeJS.Platform
|
||||||
|
osVersion: string
|
||||||
|
appVersion: string
|
||||||
|
wechatVersion: string
|
||||||
|
dataStructureVersion: string
|
||||||
|
dataDirectoryDetected: boolean
|
||||||
|
diagnosticSummary: string
|
||||||
autoDetectSupported: boolean
|
autoDetectSupported: boolean
|
||||||
wechatRunning: boolean
|
wechatRunning: boolean
|
||||||
accountIdentified: 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
|
endTime?: number
|
||||||
kinds: ExportMessageKind[]
|
kinds: ExportMessageKind[]
|
||||||
includeMedia: boolean
|
includeMedia: boolean
|
||||||
|
preferOriginal?: boolean
|
||||||
|
fallbackThumbnail?: boolean
|
||||||
|
keepMissing?: boolean
|
||||||
includeAvatars?: boolean
|
includeAvatars?: boolean
|
||||||
avatarUrls?: Record<string, string>
|
avatarUrls?: Record<string, string>
|
||||||
nameMode?: ExportNameMode
|
nameMode?: ExportNameMode
|
||||||
|
|||||||
@@ -14,6 +14,35 @@ function renderPage(
|
|||||||
dbRoot: '',
|
dbRoot: '',
|
||||||
showDbKey: false,
|
showDbKey: false,
|
||||||
isFetching: 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: '',
|
status: '',
|
||||||
statusKind: 'normal' as const,
|
statusKind: 'normal' as const,
|
||||||
showMacKeyFaq: false,
|
showMacKeyFaq: false,
|
||||||
@@ -21,8 +50,16 @@ function renderPage(
|
|||||||
onModeChange: vi.fn(),
|
onModeChange: vi.fn(),
|
||||||
onDbKeyChange: vi.fn(),
|
onDbKeyChange: vi.fn(),
|
||||||
onDbRootChange: vi.fn(),
|
onDbRootChange: vi.fn(),
|
||||||
|
onSelectAccount: vi.fn(),
|
||||||
|
onSelectDbRoot: vi.fn(),
|
||||||
onToggleDbKey: vi.fn(),
|
onToggleDbKey: vi.fn(),
|
||||||
onAutoGetKey: 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(),
|
onManualConnect: vi.fn(),
|
||||||
onPasteKey: vi.fn(),
|
onPasteKey: vi.fn(),
|
||||||
onClearKey: vi.fn(),
|
onClearKey: vi.fn(),
|
||||||
@@ -52,4 +89,48 @@ describe('DatabaseConnectionPage', () => {
|
|||||||
expect(onManualConnect).toHaveBeenCalledOnce()
|
expect(onManualConnect).toHaveBeenCalledOnce()
|
||||||
expect(screen.getByRole('button', { name: '从剪贴板粘贴并安全保存' })).toBeEnabled()
|
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()
|
await fixture.page.getByRole('tab', { name: /高级用户/ }).click()
|
||||||
const keyInput = fixture.page.getByLabel('数据库密钥')
|
const keyInput = fixture.page.getByLabel('数据库密钥')
|
||||||
await keyInput.fill('b'.repeat(64))
|
await keyInput.fill('b'.repeat(64))
|
||||||
const errorDialog = fixture.page.waitForEvent('dialog')
|
await fixture.page.getByRole('button', { name: '连接数据库' }).click()
|
||||||
await fixture.page
|
await expect(fixture.page.getByText('数据库密钥无效')).toBeVisible()
|
||||||
.getByRole('button', { name: '连接数据库' })
|
|
||||||
.evaluate((element: HTMLButtonElement) => element.click())
|
|
||||||
const dialog = await errorDialog
|
|
||||||
expect(dialog.message()).toContain('数据库密钥无效')
|
|
||||||
await dialog.dismiss()
|
|
||||||
|
|
||||||
await expect(keyInput).toBeVisible()
|
await expect(keyInput).toBeVisible()
|
||||||
await keyInput.fill('a'.repeat(64))
|
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 () => {
|
test('KEY-03 changing one key does not invalidate archive data or unrelated settings', async () => {
|
||||||
const fixture = await launchTestApp()
|
const fixture = await launchTestApp()
|
||||||
try {
|
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 fixture.page.getByRole('button', { name: '开始生成日报' }).click()
|
||||||
await expect(fixture.page.getByRole('heading', { name: '生成群聊日报' })).toBeVisible()
|
await expect(fixture.page.getByRole('heading', { name: '生成群聊日报' })).toBeVisible()
|
||||||
await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).click()
|
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: '开始生成日报' })
|
const generate = fixture.page.getByRole('button', { name: '开始生成日报' })
|
||||||
await expect(generate).toBeEnabled()
|
await expect(generate).toBeEnabled()
|
||||||
await generate.click()
|
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.getByRole('button', { name: '开始生成日报' }).click()
|
await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
|
||||||
await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).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 fixture.page.getByRole('button', { name: '开始生成日报' }).click()
|
||||||
await expect(fixture.page.getByText(/本地假服务错误 401/).first()).toBeVisible()
|
await expect(fixture.page.getByText(/本地假服务错误 401/).first()).toBeVisible()
|
||||||
await expect(fixture.page.getByRole('button', { name: '重试' })).toBeEnabled()
|
await expect(fixture.page.getByRole('button', { name: '重试' })).toBeEnabled()
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ handle('key:getSavedDbKey', () => ({
|
|||||||
saved: Boolean(savedKey),
|
saved: Boolean(savedKey),
|
||||||
encryptionAvailable: true
|
encryptionAvailable: true
|
||||||
}))
|
}))
|
||||||
handle('key:saveDbKey', (key) => {
|
handle('key:saveDbKey', (_accountRoot, key) => {
|
||||||
savedKey = String(key || '')
|
savedKey = String(key || '')
|
||||||
return { success: true, key: savedKey, saved: true, encryptionAvailable: true }
|
return { success: true, key: savedKey, saved: true, encryptionAvailable: true }
|
||||||
})
|
})
|
||||||
@@ -112,6 +112,12 @@ handle('key:clearSavedDbKey', () => {
|
|||||||
})
|
})
|
||||||
handle('key:getEnvironment', () => ({
|
handle('key:getEnvironment', () => ({
|
||||||
platform: process.platform,
|
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,
|
autoDetectSupported: true,
|
||||||
wechatRunning: true,
|
wechatRunning: true,
|
||||||
accountIdentified: connected,
|
accountIdentified: connected,
|
||||||
@@ -128,12 +134,22 @@ handle('key:autoGetImageKey', () => ({
|
|||||||
verified: true
|
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) {
|
if (key !== VALID_KEY) {
|
||||||
connected = false
|
connected = false
|
||||||
return { success: false, error: '数据库密钥无效', monitoring: false }
|
return { success: false, error: '数据库密钥无效', monitoring: false }
|
||||||
}
|
}
|
||||||
connected = true
|
connected = true
|
||||||
|
settings.dbRoot = accountRoot || settings.dbRoot
|
||||||
return { success: true, monitoring: true }
|
return { success: true, monitoring: true }
|
||||||
})
|
})
|
||||||
handle('db:testConnection', (key) =>
|
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:getStatus', () => ({ state: 'disconnected', connected: false }))
|
||||||
handle('agent-hub:getLogs', () => [])
|
handle('agent-hub:getLogs', () => [])
|
||||||
handle('app-update:getState', () => ({ status: 'idle', currentVersion: '2.1.6' }))
|
handle('app-update:getState', () => ({ status: 'idle', currentVersion: '2.1.6' }))
|
||||||
@@ -347,7 +386,6 @@ for (const channel of [
|
|||||||
'export:start',
|
'export:start',
|
||||||
'export:cancel',
|
'export:cancel',
|
||||||
'export:reveal',
|
'export:reveal',
|
||||||
'settings:selectDbRoot',
|
|
||||||
'settings:openAccountRoot',
|
'settings:openAccountRoot',
|
||||||
'db:reopenWithRoot',
|
'db:reopenWithRoot',
|
||||||
'api:skillStatus',
|
'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'])
|
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