mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-24 15:09:09 +00:00
53
docs/MAC-KEY-FAQ.md
Normal file
53
docs/MAC-KEY-FAQ.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# macOS 微信密钥自动获取失败排障指南
|
||||||
|
|
||||||
|
如果你在 macOS 系统下,遇到了 WeFlow 自动获取微信数据库密钥失败的问题,这篇指南或许可以帮到你。
|
||||||
|
|
||||||
|
### 请立刻停止连续重试
|
||||||
|
|
||||||
|
当你看到下面这些报错时,请务必暂停操作,不要再去反复点击获取:
|
||||||
|
|
||||||
|
- SCAN_FAILED,通常伴随 No suitable module found 或 Sink pattern not found
|
||||||
|
- HOOK_FAILED 或 Native Hook Failed
|
||||||
|
- patch_breakpoint_failed
|
||||||
|
- thread_get_state_failed
|
||||||
|
|
||||||
|
现在的 macOS 系统和微信防护机制非常敏锐。连续的重试动作不仅无法解决问题,反而容易被判定为异常行为,进而触发微信的安全模式或系统级的内存保护。
|
||||||
|
|
||||||
|
### 可能的尝试流程
|
||||||
|
|
||||||
|
根据大量社区用户的反馈,如果你已经遇到了获取失败的情况,按照下面的步骤顺序操作,通常都能顺利解决问题:
|
||||||
|
|
||||||
|
1. **降级微信版本**。找一个经过大家验证、兼容性更好的老版本,目前最推荐先退回到 4.1.7.57 或者 4.1.8.100。
|
||||||
|
2. **彻底退出微信**。请使用快捷键 Command + Q 或在活动监视器中结束进程,而不仅仅是关闭窗口。
|
||||||
|
3. **重启你的 Mac**。这一步极其关键,必须是真正的重新启动。注销或睡眠唤醒无法清除系统底层的拦截状态。
|
||||||
|
4. **重新打开微信**。随便点击几下保持它在最前台,并且确保它是可以正常交互的状态。
|
||||||
|
5. **回到 WeFlow**。仅仅尝试一次“自动获取密钥”。
|
||||||
|
6. **恢复日常使用**。只要成功拿到了密钥,你就可以放心地把微信更新回你平时爱用的最新版本。
|
||||||
|
|
||||||
|
### 常见报错与应对方法
|
||||||
|
|
||||||
|
为了方便排查,这里列出了几类最常见的报错及其背后的原因和对策:
|
||||||
|
|
||||||
|
**SCAN_FAILED: No suitable module found**
|
||||||
|
这意味着微信的内存布局并不标准,或者目标模块没有被命中。你可以先确保微信完整启动并保持在前台。如果还是不行,请直接执行上面提到的“降级、重启电脑、获取、再升级”的完整流程。
|
||||||
|
|
||||||
|
**SCAN_FAILED: Sink pattern not found**
|
||||||
|
这说明 WeFlow 还没有适配你当前正在使用的微信版本特征。最快的解决办法是直接降级到微信 4.1.7 或 4.1.8.100 版本再试。
|
||||||
|
|
||||||
|
**patch_breakpoint_failed 或 thread_get_state_failed**
|
||||||
|
这类错误大多是因为调试断点注入或线程状态读取被 macOS 系统的安全机制拦截了。此时继续尝试毫无意义,彻底退出微信并重启电脑再试。
|
||||||
|
|
||||||
|
**task_for_pid:5**
|
||||||
|
这是进程附加权限被系统拒绝的提示。请确保你使用的是打包好的 WeFlow.app,同时检查系统的签名与调试权限是否已经正确配置。
|
||||||
|
|
||||||
|
### 关于推荐版本的补充说明
|
||||||
|
|
||||||
|
截至 2026 年 4 月,综合社区的反馈来看,微信 4.1.7 和 4.1.8.100 版本在密钥获取流程中的表现最为稳定,成功率最高。
|
||||||
|
|
||||||
|
这并不意味着其他新版本绝对无法获取,只是作为当前的排障参考。未来 WeFlow 也会在后续的更新中逐步适配新版微信的特征,建议大家多留意项目的 Release 动态。
|
||||||
|
|
||||||
|
### 最后的几点建议
|
||||||
|
|
||||||
|
首次失败后,首要任务是排查原因,切忌盲目地连续点击自动获取。如果你在看到这篇文档前已经失败了好几次,最好的做法是直接清零重来:彻底退出微信,重启电脑,然后再进行下一次尝试。
|
||||||
|
|
||||||
|
最后,如果尝试了上述所有方法依然无法解决,请记得保存完整的报错文本,特别是 SCAN_FAILED 或 HOOK_FAILED 后面跟着的英文细节。把这些信息提交到[issue](https://github.com/hicccc77/WeFlow/issues/745),会大大加快定位和修复兼容性问题的速度。
|
||||||
@@ -24,6 +24,9 @@ export class KeyServiceMac {
|
|||||||
private machVmReadOverwrite: any = null
|
private machVmReadOverwrite: any = null
|
||||||
private machPortDeallocate: any = null
|
private machPortDeallocate: any = null
|
||||||
private _needsElevation = false
|
private _needsElevation = false
|
||||||
|
private restrictedFailureCount = 0
|
||||||
|
private restrictedFailureAt = 0
|
||||||
|
private readonly restrictedFailureWindowMs = 8 * 60_000
|
||||||
|
|
||||||
private getHelperPath(): string {
|
private getHelperPath(): string {
|
||||||
const isPackaged = app.isPackaged
|
const isPackaged = app.isPackaged
|
||||||
@@ -186,18 +189,25 @@ export class KeyServiceMac {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const errorMsg = this.mapDbKeyErrorMessage(parsed.code, parsed.detail)
|
const errorMsg = this.enrichDbKeyErrorMessage(
|
||||||
|
this.mapDbKeyErrorMessage(parsed.code, parsed.detail),
|
||||||
|
parsed.code,
|
||||||
|
parsed.detail
|
||||||
|
)
|
||||||
onStatus?.(errorMsg, 2)
|
onStatus?.(errorMsg, 2)
|
||||||
return { success: false, error: errorMsg }
|
return { success: false, error: errorMsg }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.resetRestrictedFailureState()
|
||||||
onStatus?.('密钥获取成功', 1)
|
onStatus?.('密钥获取成功', 1)
|
||||||
return { success: true, key: parsed.key }
|
return { success: true, key: parsed.key }
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('[KeyServiceMac] Error:', e)
|
console.error('[KeyServiceMac] Error:', e)
|
||||||
console.error('[KeyServiceMac] Stack:', e.stack)
|
console.error('[KeyServiceMac] Stack:', e.stack)
|
||||||
onStatus?.('获取失败: ' + e.message, 2)
|
const rawError = `${e?.message || e || ''}`.trim()
|
||||||
return { success: false, error: e.message }
|
const resolvedError = this.resolveUnexpectedDbKeyErrorMessage(rawError)
|
||||||
|
onStatus?.(resolvedError, 2)
|
||||||
|
return { success: false, error: resolvedError }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +233,149 @@ export class KeyServiceMac {
|
|||||||
return this.parseDbKeyResult(helperResult)
|
return this.parseDbKeyResult(helperResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resetRestrictedFailureState(): void {
|
||||||
|
this.restrictedFailureCount = 0
|
||||||
|
this.restrictedFailureAt = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private markRestrictedFailureAndGetCount(): number {
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - this.restrictedFailureAt > this.restrictedFailureWindowMs) {
|
||||||
|
this.restrictedFailureCount = 0
|
||||||
|
}
|
||||||
|
this.restrictedFailureAt = now
|
||||||
|
this.restrictedFailureCount += 1
|
||||||
|
return this.restrictedFailureCount
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRestrictedEnvironmentFailure(code?: string, detail?: string): boolean {
|
||||||
|
const normalizedCode = String(code || '').toUpperCase()
|
||||||
|
const normalizedDetail = String(detail || '').toLowerCase()
|
||||||
|
if (!normalizedCode && !normalizedDetail) return false
|
||||||
|
|
||||||
|
if (normalizedCode === 'SCAN_FAILED') {
|
||||||
|
return normalizedDetail.includes('sink pattern not found')
|
||||||
|
|| normalizedDetail.includes('no suitable module found')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedCode === 'HOOK_FAILED') {
|
||||||
|
return normalizedDetail.includes('patch_breakpoint_failed')
|
||||||
|
|| normalizedDetail.includes('thread_get_state_failed')
|
||||||
|
|| normalizedDetail.includes('native hook failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedCode === 'ATTACH_FAILED') {
|
||||||
|
return normalizedDetail.includes('task_for_pid:5')
|
||||||
|
|| normalizedDetail.includes('thread_get_state_failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedDetail.includes('patch_breakpoint_failed')
|
||||||
|
|| normalizedDetail.includes('thread_get_state_failed')
|
||||||
|
|| normalizedDetail.includes('sink pattern not found')
|
||||||
|
|| normalizedDetail.includes('no suitable module found')
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMacRecoveryHint(isRepeatedFailure: boolean): string {
|
||||||
|
const steps = isRepeatedFailure
|
||||||
|
? '建议步骤:彻底退出微信 -> 重启电脑(冷启动)-> 降级微信到 4.1.7 -> 仅尝试一次自动获取 -> 成功后再升级微信。'
|
||||||
|
: '建议步骤:降级微信到 4.1.7 -> 重启电脑(冷启动)-> 自动获取密钥 -> 成功后再升级微信。'
|
||||||
|
return `${steps}\n请不要连续重试,以免触发微信安全模式或系统内存保护。`
|
||||||
|
}
|
||||||
|
|
||||||
|
private simplifyDbKeyDetail(detail?: string): string {
|
||||||
|
const raw = String(detail || '')
|
||||||
|
.replace(/^WF_OK::/i, '')
|
||||||
|
.replace(/^WF_ERR::/i, '')
|
||||||
|
.replace(/\r?\n/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
if (!raw) return ''
|
||||||
|
|
||||||
|
const keys = [
|
||||||
|
'No suitable module found',
|
||||||
|
'Sink pattern not found',
|
||||||
|
'patch_breakpoint_failed',
|
||||||
|
'thread_get_state_failed',
|
||||||
|
'task_for_pid:5',
|
||||||
|
'attach_wait_timeout',
|
||||||
|
'HOOK_TIMEOUT',
|
||||||
|
'FRIDA_TIMEOUT'
|
||||||
|
]
|
||||||
|
for (const key of keys) {
|
||||||
|
if (raw.includes(key)) return key
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripped = raw
|
||||||
|
.replace(/\[xkey_helper\]/gi, ' ')
|
||||||
|
.replace(/\[debug\]/gi, ' ')
|
||||||
|
.replace(/\[\*\]/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
if (!stripped) return ''
|
||||||
|
return stripped.length > 140 ? `${stripped.slice(0, 140)}...` : stripped
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractDbKeyErrorFromAnyText(text?: string): { code?: string; detail?: string } {
|
||||||
|
const raw = String(text || '')
|
||||||
|
if (!raw) return {}
|
||||||
|
|
||||||
|
const explicit = raw.match(/ERROR:([A-Z_]+):([^\r\n]*)/)
|
||||||
|
if (explicit) {
|
||||||
|
return {
|
||||||
|
code: explicit[1] || 'UNKNOWN',
|
||||||
|
detail: this.simplifyDbKeyDetail(explicit[2] || '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.includes('No suitable module found')) {
|
||||||
|
return { code: 'SCAN_FAILED', detail: 'No suitable module found' }
|
||||||
|
}
|
||||||
|
if (raw.includes('Sink pattern not found')) {
|
||||||
|
return { code: 'SCAN_FAILED', detail: 'Sink pattern not found' }
|
||||||
|
}
|
||||||
|
if (raw.includes('patch_breakpoint_failed')) {
|
||||||
|
return { code: 'HOOK_FAILED', detail: 'patch_breakpoint_failed' }
|
||||||
|
}
|
||||||
|
if (raw.includes('thread_get_state_failed')) {
|
||||||
|
return { code: 'HOOK_FAILED', detail: 'thread_get_state_failed' }
|
||||||
|
}
|
||||||
|
if (raw.includes('task_for_pid:5')) {
|
||||||
|
return { code: 'ATTACH_FAILED', detail: 'task_for_pid:5' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveUnexpectedDbKeyErrorMessage(rawError?: string): string {
|
||||||
|
const text = String(rawError || '').trim()
|
||||||
|
const { code, detail } = this.extractDbKeyErrorFromAnyText(text)
|
||||||
|
if (code) {
|
||||||
|
const mapped = this.mapDbKeyErrorMessage(code, detail)
|
||||||
|
return this.enrichDbKeyErrorMessage(mapped, code, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('helper timeout')) {
|
||||||
|
return '获取密钥超时:请保持微信前台并进行一次会话操作后重试。'
|
||||||
|
}
|
||||||
|
if (text.includes('helper returned empty output') || text.includes('invalid json')) {
|
||||||
|
return '获取失败:helper 未返回可识别结果,请彻底退出微信后重启电脑再试。'
|
||||||
|
}
|
||||||
|
if (text.includes('xkey_helper not found')) {
|
||||||
|
return '获取失败:未找到 xkey_helper,请重新安装 WeFlow 后重试。'
|
||||||
|
}
|
||||||
|
return '自动获取密钥失败:环境可能受限或版本暂未适配,请稍后重试。'
|
||||||
|
}
|
||||||
|
|
||||||
|
private enrichDbKeyErrorMessage(baseMessage: string, code?: string, detail?: string): string {
|
||||||
|
if (!this.isRestrictedEnvironmentFailure(code, detail)) return baseMessage
|
||||||
|
|
||||||
|
const failureCount = this.markRestrictedFailureAndGetCount()
|
||||||
|
if (failureCount >= 2) {
|
||||||
|
return `${baseMessage}\n检测到连续失败,疑似已进入受限状态。请先彻底退出微信并重启电脑,再按下方步骤处理。\n${this.getMacRecoveryHint(true)}`
|
||||||
|
}
|
||||||
|
return `${baseMessage}\n${this.getMacRecoveryHint(false)}`
|
||||||
|
}
|
||||||
|
|
||||||
private async getWeChatPid(): Promise<number> {
|
private async getWeChatPid(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
// 优先使用 pgrep -x 精确匹配进程名
|
// 优先使用 pgrep -x 精确匹配进程名
|
||||||
@@ -498,7 +651,12 @@ export class KeyServiceMac {
|
|||||||
const errNum = parts[1] || 'unknown'
|
const errNum = parts[1] || 'unknown'
|
||||||
const errMsg = parts[2] || 'unknown'
|
const errMsg = parts[2] || 'unknown'
|
||||||
const partial = parts.slice(3).join('::')
|
const partial = parts.slice(3).join('::')
|
||||||
throw new Error(`elevated helper failed: errNum=${errNum}, errMsg=${errMsg}, partial=${partial || '(empty)'}`)
|
if (errNum === '-128' || String(errMsg).includes('User canceled')) {
|
||||||
|
throw new Error('User canceled')
|
||||||
|
}
|
||||||
|
const inferred = this.extractDbKeyErrorFromAnyText(`${errMsg}\n${partial}`)
|
||||||
|
if (inferred.code) return `ERROR:${inferred.code}:${inferred.detail || ''}`
|
||||||
|
throw new Error(`elevated helper failed: errNum=${errNum}, errMsg=${this.simplifyDbKeyDetail(errMsg) || 'unknown'}`)
|
||||||
}
|
}
|
||||||
const normalizedOutput = joined.startsWith('WF_OK::') ? joined.slice('WF_OK::'.length) : joined
|
const normalizedOutput = joined.startsWith('WF_OK::') ? joined.slice('WF_OK::'.length) : joined
|
||||||
|
|
||||||
@@ -520,49 +678,57 @@ export class KeyServiceMac {
|
|||||||
// 其次找 result 字段
|
// 其次找 result 字段
|
||||||
const resultPayload = allJson.find(p => typeof p?.result === 'string')
|
const resultPayload = allJson.find(p => typeof p?.result === 'string')
|
||||||
if (resultPayload) return resultPayload.result
|
if (resultPayload) return resultPayload.result
|
||||||
throw new Error('elevated helper returned invalid json: ' + lines[lines.length - 1])
|
const inferred = this.extractDbKeyErrorFromAnyText(normalizedOutput)
|
||||||
|
if (inferred.code) return `ERROR:${inferred.code}:${inferred.detail || ''}`
|
||||||
|
throw new Error('elevated helper returned invalid output')
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapDbKeyErrorMessage(code?: string, detail?: string): string {
|
private mapDbKeyErrorMessage(code?: string, detail?: string): string {
|
||||||
|
const normalizedDetail = this.simplifyDbKeyDetail(detail)
|
||||||
if (code === 'PROCESS_NOT_FOUND') return '微信进程未运行'
|
if (code === 'PROCESS_NOT_FOUND') return '微信进程未运行'
|
||||||
if (code === 'ATTACH_FAILED') {
|
if (code === 'ATTACH_FAILED') {
|
||||||
const isDevElectron = process.execPath.includes('/node_modules/electron/')
|
const isDevElectron = process.execPath.includes('/node_modules/electron/')
|
||||||
if ((detail || '').includes('task_for_pid:5')) {
|
if (normalizedDetail.includes('task_for_pid:5')) {
|
||||||
if (isDevElectron) {
|
if (isDevElectron) {
|
||||||
return `无法附加到微信进程(task_for_pid 被拒绝)。当前为开发环境 Electron:${process.execPath}\n建议使用打包后的 WeFlow.app(已携带调试 entitlements)再重试。`
|
return `无法附加到微信进程(task_for_pid 被拒绝)。当前为开发环境 Electron:${process.execPath}\n建议使用打包后的 WeFlow.app(已携带调试 entitlements)再重试。`
|
||||||
}
|
}
|
||||||
return '无法附加到微信进程(task_for_pid 被系统拒绝)。请确认当前运行程序已正确签名并包含调试 entitlements。'
|
return '无法附加到微信进程(task_for_pid 被系统拒绝)。请确认当前运行程序已正确签名并包含调试 entitlements,优先使用打包版 WeFlow.app。'
|
||||||
}
|
}
|
||||||
return `无法附加到进程 (${detail || ''})`
|
if (normalizedDetail.includes('thread_get_state_failed')) {
|
||||||
|
return `无法附加到进程:系统拒绝读取线程状态(${normalizedDetail})。`
|
||||||
|
}
|
||||||
|
return `无法附加到进程 (${normalizedDetail || ''})`
|
||||||
}
|
}
|
||||||
if (code === 'FRIDA_FAILED') {
|
if (code === 'FRIDA_FAILED') {
|
||||||
if ((detail || '').includes('FRIDA_TIMEOUT')) {
|
if (normalizedDetail.includes('FRIDA_TIMEOUT')) {
|
||||||
return '定位已成功但在等待时间内未捕获到密钥调用。请保持微信前台并进行一次会话/数据库访问后重试。'
|
return '定位已成功但在等待时间内未捕获到密钥调用。请保持微信前台并进行一次会话/数据库访问后重试。'
|
||||||
}
|
}
|
||||||
return `Frida 语义定位失败 (${detail || ''})`
|
return `Frida 语义定位失败 (${normalizedDetail || ''})`
|
||||||
}
|
}
|
||||||
if (code === 'HOOK_FAILED') {
|
if (code === 'HOOK_FAILED') {
|
||||||
if ((detail || '').includes('HOOK_TIMEOUT')) {
|
if (normalizedDetail.includes('HOOK_TIMEOUT')) {
|
||||||
return 'Hook 已安装,但在等待时间内未触发目标函数。请保持微信前台并执行一次会话/数据库访问后重试。'
|
return 'Hook 已安装,但在等待时间内未触发目标函数。请保持微信前台并执行一次会话/数据库访问后重试。'
|
||||||
}
|
}
|
||||||
if ((detail || '').includes('attach_wait_timeout')) {
|
if (normalizedDetail.includes('attach_wait_timeout')) {
|
||||||
return '附加调试器超时,未能进入 Hook 阶段。请确认微信处于可交互状态并重试。'
|
return '附加调试器超时,未能进入 Hook 阶段。请确认微信处于可交互状态并重试。'
|
||||||
}
|
}
|
||||||
return `原生 Hook 失败 (${detail || ''})`
|
if (normalizedDetail.includes('patch_breakpoint_failed') || normalizedDetail.includes('thread_get_state_failed')) {
|
||||||
|
return `原生 Hook 失败:检测到系统调试权限或内存保护冲突(${normalizedDetail})。`
|
||||||
|
}
|
||||||
|
return `原生 Hook 失败 (${normalizedDetail || ''})`
|
||||||
}
|
}
|
||||||
if (code === 'HOOK_TARGET_ONLY') {
|
if (code === 'HOOK_TARGET_ONLY') {
|
||||||
return `已定位到目标函数地址(${detail || ''}),但当前原生 C++ 仅完成定位,尚未完成远程 Hook 回调取 key 流程。`
|
return `已定位到目标函数地址(${normalizedDetail || ''}),但当前原生 C++ 仅完成定位,尚未完成远程 Hook 回调取 key 流程。`
|
||||||
}
|
}
|
||||||
if (code === 'SCAN_FAILED') {
|
if (code === 'SCAN_FAILED') {
|
||||||
const normalizedDetail = (detail || '').trim()
|
|
||||||
if (!normalizedDetail) {
|
if (!normalizedDetail) {
|
||||||
return '内存扫描失败:未匹配到可用特征。可能是当前微信版本更新导致,请升级 WeFlow 后重试。'
|
return '内存扫描失败:未匹配到可用特征。可能是当前微信版本更新导致,请升级 WeFlow 后重试。'
|
||||||
}
|
}
|
||||||
if (normalizedDetail.includes('Sink pattern not found')) {
|
if (normalizedDetail.includes('Sink pattern not found')) {
|
||||||
return '内存扫描失败:未匹配到目标函数特征,可使用微信 4.1.8.100 版本尝试。'
|
return '内存扫描失败:未匹配到目标函数特征(Sink pattern not found),当前微信版本可能暂未适配。'
|
||||||
}
|
}
|
||||||
if (normalizedDetail.includes('No suitable module found')) {
|
if (normalizedDetail.includes('No suitable module found')) {
|
||||||
return '内存扫描失败:未找到可扫描的微信主模块。请确认微信已完整启动并保持前台,再重试。'
|
return '内存扫描失败:未找到可扫描的微信主模块。请确认微信已完整启动并保持前台;若仍失败,优先尝试微信 4.1.7。'
|
||||||
}
|
}
|
||||||
return `内存扫描失败:${normalizedDetail}`
|
return `内存扫描失败:${normalizedDetail}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ interface DualReportData {
|
|||||||
friendExclusivePhrases: Array<{ phrase: string; count: number }>
|
friendExclusivePhrases: Array<{ phrase: string; count: number }>
|
||||||
heatmap?: number[][]
|
heatmap?: number[][]
|
||||||
initiative?: { initiated: number; received: number }
|
initiative?: { initiated: number; received: number }
|
||||||
response?: { avg: number; fastest: number; slowest: number; count: number }
|
response?: { avg: number; fastest: number; slowest?: number; count: number }
|
||||||
monthly?: Record<string, number>
|
monthly?: Record<string, number>
|
||||||
streak?: { days: number; startDate: string; endDate: string }
|
streak?: { days: number; startDate: string; endDate: string }
|
||||||
}
|
}
|
||||||
@@ -149,7 +149,7 @@ function DualReportWindow() {
|
|||||||
|
|
||||||
const generateReport = async (friendUsername: string, year: number) => {
|
const generateReport = async (friendUsername: string, year: number) => {
|
||||||
const taskId = registerBackgroundTask({
|
const taskId = registerBackgroundTask({
|
||||||
sourcePage: 'dualReport',
|
sourcePage: 'annualReport',
|
||||||
title: '双人报告生成',
|
title: '双人报告生成',
|
||||||
detail: `正在生成 ${year === 0 ? '历史以来' : year + '年'} 双人年度报告`,
|
detail: `正在生成 ${year === 0 ? '历史以来' : year + '年'} 双人年度报告`,
|
||||||
progressText: '初始化',
|
progressText: '初始化',
|
||||||
@@ -302,6 +302,17 @@ function DualReportWindow() {
|
|||||||
const handleClose = () => { navigate('/home') }
|
const handleClose = () => { navigate('/home') }
|
||||||
|
|
||||||
const formatFileYearLabel = (year: number) => (year === 0 ? '历史以来' : String(year))
|
const formatFileYearLabel = (year: number) => (year === 0 ? '历史以来' : String(year))
|
||||||
|
const formatMonthDayTime = (timestamp?: number) => {
|
||||||
|
if (!timestamp || Number.isNaN(timestamp)) return ''
|
||||||
|
const msTimestamp = timestamp > 1e12 ? timestamp : timestamp * 1000
|
||||||
|
const date = new Date(msTimestamp)
|
||||||
|
if (Number.isNaN(date.getTime())) return ''
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const hour = String(date.getHours()).padStart(2, '0')
|
||||||
|
const minute = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
return `${month}-${day} ${hour}:${minute}`
|
||||||
|
}
|
||||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
const waitForNextPaint = () => new Promise<void>((resolve) => {
|
const waitForNextPaint = () => new Promise<void>((resolve) => {
|
||||||
requestAnimationFrame(() => { requestAnimationFrame(() => resolve()) })
|
requestAnimationFrame(() => { requestAnimationFrame(() => resolve()) })
|
||||||
@@ -427,7 +438,11 @@ function DualReportWindow() {
|
|||||||
|
|
||||||
// 计算第一句话数据
|
// 计算第一句话数据
|
||||||
const displayFirstChat = reportData.yearFirstChat || reportData.firstChat
|
const displayFirstChat = reportData.yearFirstChat || reportData.firstChat
|
||||||
const firstChatArray = (reportData.yearFirstChatMessages || reportData.firstChatMessages || (displayFirstChat ? [displayFirstChat] : [])).slice(0, 3)
|
const firstChatArray = (
|
||||||
|
reportData.yearFirstChat?.firstThreeMessages ||
|
||||||
|
reportData.firstChatMessages ||
|
||||||
|
(displayFirstChat ? [displayFirstChat] : [])
|
||||||
|
).slice(0, 3)
|
||||||
|
|
||||||
// 聊天火花
|
// 聊天火花
|
||||||
const showSpark = reportData.streak && reportData.streak.days > 0
|
const showSpark = reportData.streak && reportData.streak.days > 0
|
||||||
@@ -487,8 +502,8 @@ function DualReportWindow() {
|
|||||||
<div className="reveal-wrap"><h2 className="reveal-inner title delay-2">故事的开始</h2></div>
|
<div className="reveal-wrap"><h2 className="reveal-inner title delay-2">故事的开始</h2></div>
|
||||||
<div className="s1-messages reveal-inner delay-3">
|
<div className="s1-messages reveal-inner delay-3">
|
||||||
{firstChatArray.map((chat: any, idx: number) => (
|
{firstChatArray.map((chat: any, idx: number) => (
|
||||||
<div key={idx} className={`s1-message-item ${chat.sender === 'self' ? 'sent' : ''}`}>
|
<div key={idx} className={`s1-message-item ${chat.isSentByMe ? 'sent' : ''}`}>
|
||||||
<span className="s1-meta">{chat.createTimeStr || formatMonthDayTime(chat.timestamp)}</span>
|
<span className="s1-meta">{chat.createTimeStr || formatMonthDayTime(chat.createTime)}</span>
|
||||||
<div className="scene-bubble s1-bubble">{formatFirstChat(chat.content)}</div>
|
<div className="scene-bubble s1-bubble">{formatFirstChat(chat.content)}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import type { SnsPost } from '../types/sns'
|
|||||||
import {
|
import {
|
||||||
cloneExportDateRange,
|
cloneExportDateRange,
|
||||||
cloneExportDateRangeSelection,
|
cloneExportDateRangeSelection,
|
||||||
|
createExportDateRangeSelectionFromPreset,
|
||||||
createDateRangeByLastNDays,
|
createDateRangeByLastNDays,
|
||||||
createDefaultDateRange,
|
createDefaultDateRange,
|
||||||
createDefaultExportDateRangeSelection,
|
createDefaultExportDateRangeSelection,
|
||||||
@@ -1599,6 +1600,19 @@ const areExportSelectionsEqual = (left: ExportDateRangeSelection, right: ExportD
|
|||||||
left.dateRange.end.getTime() === right.dateRange.end.getTime()
|
left.dateRange.end.getTime() === right.dateRange.end.getTime()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const resolveDynamicExportSelection = (
|
||||||
|
selection: ExportDateRangeSelection,
|
||||||
|
now = new Date()
|
||||||
|
): ExportDateRangeSelection => {
|
||||||
|
if (selection.useAllTime) {
|
||||||
|
return cloneExportDateRangeSelection(selection)
|
||||||
|
}
|
||||||
|
if (selection.preset === 'custom') {
|
||||||
|
return cloneExportDateRangeSelection(selection)
|
||||||
|
}
|
||||||
|
return createExportDateRangeSelectionFromPreset(selection.preset, now)
|
||||||
|
}
|
||||||
|
|
||||||
const pickSessionMediaMetric = (
|
const pickSessionMediaMetric = (
|
||||||
metricRaw: SessionExportMetric | SessionContentMetric | undefined
|
metricRaw: SessionExportMetric | SessionContentMetric | undefined
|
||||||
): SessionContentMetric | null => {
|
): SessionContentMetric | null => {
|
||||||
@@ -4790,19 +4804,20 @@ function ExportPage() {
|
|||||||
const clearSelection = () => setSelectedSessions(new Set())
|
const clearSelection = () => setSelectedSessions(new Set())
|
||||||
|
|
||||||
const openExportDialog = useCallback((payload: Omit<ExportDialogState, 'open' | 'intent'> & { intent?: ExportDialogState['intent'] }) => {
|
const openExportDialog = useCallback((payload: Omit<ExportDialogState, 'open' | 'intent'> & { intent?: ExportDialogState['intent'] }) => {
|
||||||
|
const dynamicDefaultRangeSelection = resolveDynamicExportSelection(exportDefaultDateRangeSelection, new Date())
|
||||||
setExportDialog({ open: true, intent: payload.intent || 'manual', ...payload })
|
setExportDialog({ open: true, intent: payload.intent || 'manual', ...payload })
|
||||||
setIsTimeRangeDialogOpen(false)
|
setIsTimeRangeDialogOpen(false)
|
||||||
setTimeRangeBounds(null)
|
setTimeRangeBounds(null)
|
||||||
setTimeRangeSelection(exportDefaultDateRangeSelection)
|
setTimeRangeSelection(dynamicDefaultRangeSelection)
|
||||||
|
|
||||||
setOptions(prev => {
|
setOptions(prev => {
|
||||||
const nextDateRange = cloneExportDateRange(exportDefaultDateRangeSelection.dateRange)
|
const nextDateRange = cloneExportDateRange(dynamicDefaultRangeSelection.dateRange)
|
||||||
|
|
||||||
const next: ExportOptions = {
|
const next: ExportOptions = {
|
||||||
...prev,
|
...prev,
|
||||||
format: exportDefaultFormat,
|
format: exportDefaultFormat,
|
||||||
exportAvatars: exportDefaultAvatars,
|
exportAvatars: exportDefaultAvatars,
|
||||||
useAllTime: exportDefaultDateRangeSelection.useAllTime,
|
useAllTime: dynamicDefaultRangeSelection.useAllTime,
|
||||||
dateRange: nextDateRange,
|
dateRange: nextDateRange,
|
||||||
exportMedia: Boolean(
|
exportMedia: Boolean(
|
||||||
exportDefaultMedia.images ||
|
exportDefaultMedia.images ||
|
||||||
@@ -4863,9 +4878,13 @@ function ExportPage() {
|
|||||||
setTimeRangeBounds(null)
|
setTimeRangeBounds(null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const resolveChatExportTimeRangeBounds = useCallback(async (sessionIds: string[]): Promise<TimeRangeBounds | null> => {
|
const resolveChatExportTimeRangeBounds = useCallback(async (
|
||||||
|
sessionIds: string[],
|
||||||
|
options?: { forceRefresh?: boolean }
|
||||||
|
): Promise<TimeRangeBounds | null> => {
|
||||||
const normalizedSessionIds = Array.from(new Set((sessionIds || []).map(id => String(id || '').trim()).filter(Boolean)))
|
const normalizedSessionIds = Array.from(new Set((sessionIds || []).map(id => String(id || '').trim()).filter(Boolean)))
|
||||||
if (normalizedSessionIds.length === 0) return null
|
if (normalizedSessionIds.length === 0) return null
|
||||||
|
const forceRefresh = options?.forceRefresh === true
|
||||||
|
|
||||||
const sessionRowMap = new Map<string, SessionRow>()
|
const sessionRowMap = new Map<string, SessionRow>()
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
@@ -4928,29 +4947,36 @@ function ExportPage() {
|
|||||||
return !resolved?.hasMin || !resolved?.hasMax
|
return !resolved?.hasMin || !resolved?.hasMax
|
||||||
})
|
})
|
||||||
|
|
||||||
const staleSessionIds = new Set<string>()
|
if (forceRefresh) {
|
||||||
|
|
||||||
if (missingSessionIds().length > 0) {
|
|
||||||
const cacheResult = await window.electronAPI.chat.getExportSessionStats(
|
|
||||||
missingSessionIds(),
|
|
||||||
{ includeRelations: false, allowStaleCache: true, cacheOnly: true }
|
|
||||||
)
|
|
||||||
applyStatsResult(cacheResult)
|
|
||||||
for (const sessionId of cacheResult?.needsRefresh || []) {
|
|
||||||
staleSessionIds.add(String(sessionId || '').trim())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionsNeedingFreshStats = Array.from(new Set([
|
|
||||||
...missingSessionIds(),
|
|
||||||
...Array.from(staleSessionIds).filter(Boolean)
|
|
||||||
]))
|
|
||||||
|
|
||||||
if (sessionsNeedingFreshStats.length > 0) {
|
|
||||||
applyStatsResult(await window.electronAPI.chat.getExportSessionStats(
|
applyStatsResult(await window.electronAPI.chat.getExportSessionStats(
|
||||||
sessionsNeedingFreshStats,
|
normalizedSessionIds,
|
||||||
{ includeRelations: false }
|
{ includeRelations: false, forceRefresh: true }
|
||||||
))
|
))
|
||||||
|
} else {
|
||||||
|
const staleSessionIds = new Set<string>()
|
||||||
|
|
||||||
|
if (missingSessionIds().length > 0) {
|
||||||
|
const cacheResult = await window.electronAPI.chat.getExportSessionStats(
|
||||||
|
missingSessionIds(),
|
||||||
|
{ includeRelations: false, allowStaleCache: true, cacheOnly: true }
|
||||||
|
)
|
||||||
|
applyStatsResult(cacheResult)
|
||||||
|
for (const sessionId of cacheResult?.needsRefresh || []) {
|
||||||
|
staleSessionIds.add(String(sessionId || '').trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionsNeedingFreshStats = Array.from(new Set([
|
||||||
|
...missingSessionIds(),
|
||||||
|
...Array.from(staleSessionIds).filter(Boolean)
|
||||||
|
]))
|
||||||
|
|
||||||
|
if (sessionsNeedingFreshStats.length > 0) {
|
||||||
|
applyStatsResult(await window.electronAPI.chat.getExportSessionStats(
|
||||||
|
sessionsNeedingFreshStats,
|
||||||
|
{ includeRelations: false }
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (missingSessionIds().length > 0) {
|
if (missingSessionIds().length > 0) {
|
||||||
@@ -4971,14 +4997,26 @@ function ExportPage() {
|
|||||||
if (isResolvingTimeRangeBounds) return
|
if (isResolvingTimeRangeBounds) return
|
||||||
setIsResolvingTimeRangeBounds(true)
|
setIsResolvingTimeRangeBounds(true)
|
||||||
try {
|
try {
|
||||||
|
const liveSelection = resolveDynamicExportSelection(timeRangeSelection, new Date())
|
||||||
|
if (!areExportSelectionsEqual(liveSelection, timeRangeSelection)) {
|
||||||
|
setTimeRangeSelection(liveSelection)
|
||||||
|
setOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
useAllTime: liveSelection.useAllTime,
|
||||||
|
dateRange: cloneExportDateRange(liveSelection.dateRange)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
let nextBounds: TimeRangeBounds | null = null
|
let nextBounds: TimeRangeBounds | null = null
|
||||||
if (exportDialog.scope !== 'sns') {
|
if (exportDialog.scope !== 'sns') {
|
||||||
nextBounds = await resolveChatExportTimeRangeBounds(exportDialog.sessionIds)
|
nextBounds = await resolveChatExportTimeRangeBounds(exportDialog.sessionIds, {
|
||||||
|
forceRefresh: exportDialog.scope === 'single'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
setTimeRangeBounds(nextBounds)
|
setTimeRangeBounds(nextBounds)
|
||||||
if (nextBounds) {
|
if (nextBounds) {
|
||||||
const nextSelection = clampExportSelectionToBounds(timeRangeSelection, nextBounds)
|
const nextSelection = clampExportSelectionToBounds(liveSelection, nextBounds)
|
||||||
if (!areExportSelectionsEqual(nextSelection, timeRangeSelection)) {
|
if (!areExportSelectionsEqual(nextSelection, liveSelection)) {
|
||||||
setTimeRangeSelection(nextSelection)
|
setTimeRangeSelection(nextSelection)
|
||||||
setOptions(prev => ({
|
setOptions(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -5056,47 +5094,51 @@ function ExportPage() {
|
|||||||
return unsubscribe
|
return unsubscribe
|
||||||
}, [loadBaseConfig, openExportDialog])
|
}, [loadBaseConfig, openExportDialog])
|
||||||
|
|
||||||
const buildExportOptions = (scope: TaskScope, contentType?: ContentType): ElectronExportOptions => {
|
const buildExportOptions = (
|
||||||
|
scope: TaskScope,
|
||||||
|
contentType?: ContentType,
|
||||||
|
sourceOptions: ExportOptions = options
|
||||||
|
): ElectronExportOptions => {
|
||||||
const sessionLayout: SessionLayout = writeLayout === 'C' ? 'per-session' : 'shared'
|
const sessionLayout: SessionLayout = writeLayout === 'C' ? 'per-session' : 'shared'
|
||||||
const exportMediaEnabled = Boolean(
|
const exportMediaEnabled = Boolean(
|
||||||
options.exportImages ||
|
sourceOptions.exportImages ||
|
||||||
options.exportVoices ||
|
sourceOptions.exportVoices ||
|
||||||
options.exportVideos ||
|
sourceOptions.exportVideos ||
|
||||||
options.exportEmojis ||
|
sourceOptions.exportEmojis ||
|
||||||
options.exportFiles
|
sourceOptions.exportFiles
|
||||||
)
|
)
|
||||||
|
|
||||||
const base: ElectronExportOptions = {
|
const base: ElectronExportOptions = {
|
||||||
format: options.format,
|
format: sourceOptions.format,
|
||||||
exportAvatars: options.exportAvatars,
|
exportAvatars: sourceOptions.exportAvatars,
|
||||||
exportMedia: exportMediaEnabled,
|
exportMedia: exportMediaEnabled,
|
||||||
exportImages: options.exportImages,
|
exportImages: sourceOptions.exportImages,
|
||||||
exportVoices: options.exportVoices,
|
exportVoices: sourceOptions.exportVoices,
|
||||||
exportVideos: options.exportVideos,
|
exportVideos: sourceOptions.exportVideos,
|
||||||
exportEmojis: options.exportEmojis,
|
exportEmojis: sourceOptions.exportEmojis,
|
||||||
exportFiles: options.exportFiles,
|
exportFiles: sourceOptions.exportFiles,
|
||||||
maxFileSizeMb: options.maxFileSizeMb,
|
maxFileSizeMb: sourceOptions.maxFileSizeMb,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: sourceOptions.exportVoiceAsText,
|
||||||
excelCompactColumns: options.excelCompactColumns,
|
excelCompactColumns: sourceOptions.excelCompactColumns,
|
||||||
txtColumns: options.txtColumns,
|
txtColumns: sourceOptions.txtColumns,
|
||||||
displayNamePreference: options.displayNamePreference,
|
displayNamePreference: sourceOptions.displayNamePreference,
|
||||||
exportConcurrency: options.exportConcurrency,
|
exportConcurrency: sourceOptions.exportConcurrency,
|
||||||
fileNamingMode: exportDefaultFileNamingMode,
|
fileNamingMode: exportDefaultFileNamingMode,
|
||||||
sessionLayout,
|
sessionLayout,
|
||||||
sessionNameWithTypePrefix,
|
sessionNameWithTypePrefix,
|
||||||
dateRange: options.useAllTime
|
dateRange: sourceOptions.useAllTime
|
||||||
? null
|
? null
|
||||||
: options.dateRange
|
: sourceOptions.dateRange
|
||||||
? {
|
? {
|
||||||
start: Math.floor(options.dateRange.start.getTime() / 1000),
|
start: Math.floor(sourceOptions.dateRange.start.getTime() / 1000),
|
||||||
end: Math.floor(options.dateRange.end.getTime() / 1000)
|
end: Math.floor(sourceOptions.dateRange.end.getTime() / 1000)
|
||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scope === 'content' && contentType) {
|
if (scope === 'content' && contentType) {
|
||||||
if (contentType === 'text') {
|
if (contentType === 'text') {
|
||||||
const textExportConcurrency = Math.min(2, Math.max(1, base.exportConcurrency ?? options.exportConcurrency))
|
const textExportConcurrency = Math.min(2, Math.max(1, base.exportConcurrency ?? sourceOptions.exportConcurrency))
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
contentType,
|
contentType,
|
||||||
@@ -5127,14 +5169,14 @@ function ExportPage() {
|
|||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildSnsExportOptions = () => {
|
const buildSnsExportOptions = (sourceOptions: ExportOptions = options) => {
|
||||||
const format: SnsTimelineExportFormat = snsExportFormat
|
const format: SnsTimelineExportFormat = snsExportFormat
|
||||||
const dateRange = options.useAllTime
|
const dateRange = sourceOptions.useAllTime
|
||||||
? null
|
? null
|
||||||
: options.dateRange
|
: sourceOptions.dateRange
|
||||||
? {
|
? {
|
||||||
startTime: Math.floor(options.dateRange.start.getTime() / 1000),
|
startTime: Math.floor(sourceOptions.dateRange.start.getTime() / 1000),
|
||||||
endTime: Math.floor(options.dateRange.end.getTime() / 1000)
|
endTime: Math.floor(sourceOptions.dateRange.end.getTime() / 1000)
|
||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
|
|
||||||
@@ -5946,12 +5988,27 @@ function ExportPage() {
|
|||||||
if (!exportDialog.open || !exportFolder) return
|
if (!exportDialog.open || !exportFolder) return
|
||||||
if (exportDialog.scope !== 'sns' && exportDialog.sessionIds.length === 0) return
|
if (exportDialog.scope !== 'sns' && exportDialog.sessionIds.length === 0) return
|
||||||
|
|
||||||
|
const effectiveRangeSelection = resolveDynamicExportSelection(timeRangeSelection, new Date())
|
||||||
|
if (!areExportSelectionsEqual(effectiveRangeSelection, timeRangeSelection)) {
|
||||||
|
setTimeRangeSelection(effectiveRangeSelection)
|
||||||
|
}
|
||||||
|
const effectiveOptionsState: ExportOptions = {
|
||||||
|
...options,
|
||||||
|
useAllTime: effectiveRangeSelection.useAllTime,
|
||||||
|
dateRange: cloneExportDateRange(effectiveRangeSelection.dateRange)
|
||||||
|
}
|
||||||
|
setOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
useAllTime: effectiveOptionsState.useAllTime,
|
||||||
|
dateRange: cloneExportDateRange(effectiveRangeSelection.dateRange)
|
||||||
|
}))
|
||||||
|
|
||||||
const isAutomationCreateIntent = exportDialog.intent === 'automation-create'
|
const isAutomationCreateIntent = exportDialog.intent === 'automation-create'
|
||||||
const exportOptions = exportDialog.scope === 'sns'
|
const exportOptions = exportDialog.scope === 'sns'
|
||||||
? undefined
|
? undefined
|
||||||
: buildExportOptions(exportDialog.scope, exportDialog.contentType)
|
: buildExportOptions(exportDialog.scope, exportDialog.contentType, effectiveOptionsState)
|
||||||
const snsOptions = exportDialog.scope === 'sns'
|
const snsOptions = exportDialog.scope === 'sns'
|
||||||
? buildSnsExportOptions()
|
? buildSnsExportOptions(effectiveOptionsState)
|
||||||
: undefined
|
: undefined
|
||||||
const title =
|
const title =
|
||||||
exportDialog.scope === 'single'
|
exportDialog.scope === 'single'
|
||||||
@@ -5968,7 +6025,7 @@ function ExportPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const { dateRange: _discard, ...optionTemplate } = exportOptions
|
const { dateRange: _discard, ...optionTemplate } = exportOptions
|
||||||
const normalizedRangeSelection = cloneExportDateRangeSelection(timeRangeSelection)
|
const normalizedRangeSelection = cloneExportDateRangeSelection(effectiveRangeSelection)
|
||||||
const scope = exportDialog.scope === 'single'
|
const scope = exportDialog.scope === 'single'
|
||||||
? 'single'
|
? 'single'
|
||||||
: exportDialog.scope === 'content'
|
: exportDialog.scope === 'content'
|
||||||
|
|||||||
@@ -338,6 +338,22 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mac-key-faq-link {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #0f62fe;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 0;
|
||||||
|
margin-top: 6px;
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.manual-prompt {
|
.manual-prompt {
|
||||||
background: rgba(139, 115, 85, 0.1);
|
background: rgba(139, 115, 85, 0.1);
|
||||||
border: 1px dashed rgba(139, 115, 85, 0.3);
|
border: 1px dashed rgba(139, 115, 85, 0.3);
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ const aiTabs: Array<{ id: Extract<SettingsTab, 'aiCommon' | 'insight' | 'aiFootp
|
|||||||
const isMac = navigator.userAgent.toLowerCase().includes('mac')
|
const isMac = navigator.userAgent.toLowerCase().includes('mac')
|
||||||
const isLinux = navigator.userAgent.toLowerCase().includes('linux')
|
const isLinux = navigator.userAgent.toLowerCase().includes('linux')
|
||||||
const isWindows = !isMac && !isLinux
|
const isWindows = !isMac && !isLinux
|
||||||
|
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
|
||||||
|
|
||||||
const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录'
|
const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录'
|
||||||
const dbPathPlaceholder = isMac
|
const dbPathPlaceholder = isMac
|
||||||
@@ -225,6 +226,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null)
|
const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null)
|
||||||
const [showDecryptKey, setShowDecryptKey] = useState(false)
|
const [showDecryptKey, setShowDecryptKey] = useState(false)
|
||||||
const [dbKeyStatus, setDbKeyStatus] = useState('')
|
const [dbKeyStatus, setDbKeyStatus] = useState('')
|
||||||
|
const [dbKeyError, setDbKeyError] = useState('')
|
||||||
const [imageKeyStatus, setImageKeyStatus] = useState('')
|
const [imageKeyStatus, setImageKeyStatus] = useState('')
|
||||||
const [isManualStartPrompt, setIsManualStartPrompt] = useState(false)
|
const [isManualStartPrompt, setIsManualStartPrompt] = useState(false)
|
||||||
const [isClearingAnalyticsCache, setIsClearingAnalyticsCache] = useState(false)
|
const [isClearingAnalyticsCache, setIsClearingAnalyticsCache] = useState(false)
|
||||||
@@ -1254,12 +1256,14 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
if (isFetchingDbKey) return
|
if (isFetchingDbKey) return
|
||||||
setIsFetchingDbKey(true)
|
setIsFetchingDbKey(true)
|
||||||
setIsManualStartPrompt(false)
|
setIsManualStartPrompt(false)
|
||||||
|
setDbKeyError('')
|
||||||
setDbKeyStatus('正在连接微信进程...')
|
setDbKeyStatus('正在连接微信进程...')
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.key.autoGetDbKey()
|
const result = await window.electronAPI.key.autoGetDbKey()
|
||||||
if (result.success && result.key) {
|
if (result.success && result.key) {
|
||||||
setDecryptKey(result.key)
|
setDecryptKey(result.key)
|
||||||
setDbKeyStatus('密钥获取成功')
|
setDbKeyStatus('密钥获取成功')
|
||||||
|
setDbKeyError('')
|
||||||
showMessage('已自动获取解密密钥', true)
|
showMessage('已自动获取解密密钥', true)
|
||||||
await syncCurrentKeys({ decryptKey: result.key, wxid })
|
await syncCurrentKeys({ decryptKey: result.key, wxid })
|
||||||
const keysOverride = buildKeysFromInputs({ decryptKey: result.key })
|
const keysOverride = buildKeysFromInputs({ decryptKey: result.key })
|
||||||
@@ -1274,17 +1278,26 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
) {
|
) {
|
||||||
setIsManualStartPrompt(true)
|
setIsManualStartPrompt(true)
|
||||||
setDbKeyStatus('需要手动启动微信')
|
setDbKeyStatus('需要手动启动微信')
|
||||||
|
setDbKeyError('')
|
||||||
} else {
|
} else {
|
||||||
showMessage(result.error || '自动获取密钥失败', false)
|
const failureMessage = result.error || '自动获取密钥失败'
|
||||||
|
setDbKeyError(failureMessage)
|
||||||
|
showMessage(failureMessage, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
showMessage(`自动获取密钥失败: ${e}`, false)
|
const failureMessage = `自动获取密钥失败: ${e}`
|
||||||
|
setDbKeyError(failureMessage)
|
||||||
|
showMessage(failureMessage, false)
|
||||||
} finally {
|
} finally {
|
||||||
setIsFetchingDbKey(false)
|
setIsFetchingDbKey(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openMacKeyFaq = () => {
|
||||||
|
void window.electronAPI.shell.openExternal(MAC_KEY_FAQ_URL)
|
||||||
|
}
|
||||||
|
|
||||||
const handleManualConfirm = async () => {
|
const handleManualConfirm = async () => {
|
||||||
setIsManualStartPrompt(false)
|
setIsManualStartPrompt(false)
|
||||||
handleAutoGetDbKey()
|
handleAutoGetDbKey()
|
||||||
@@ -2207,6 +2220,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{dbKeyStatus && <div className="form-hint status-text">{dbKeyStatus}</div>}
|
{dbKeyStatus && <div className="form-hint status-text">{dbKeyStatus}</div>}
|
||||||
|
{isMac && dbKeyError && (
|
||||||
|
<button type="button" className="mac-key-faq-link" onClick={openMacKeyFaq}>
|
||||||
|
查看 macOS 获取密钥排障指引
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
|
|||||||
@@ -666,7 +666,28 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-link-btn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #0f62fe;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.intro-footer {
|
.intro-footer {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import './WelcomePage.scss'
|
|||||||
const isMac = navigator.userAgent.toLowerCase().includes('mac')
|
const isMac = navigator.userAgent.toLowerCase().includes('mac')
|
||||||
const isLinux = navigator.userAgent.toLowerCase().includes('linux')
|
const isLinux = navigator.userAgent.toLowerCase().includes('linux')
|
||||||
const isWindows = !isMac && !isLinux
|
const isWindows = !isMac && !isLinux
|
||||||
|
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
|
||||||
|
|
||||||
const DB_PATH_CHINESE_ERROR = '路径包含中文字符,迁移至全英文目录后再试'
|
const DB_PATH_CHINESE_ERROR = '路径包含中文字符,迁移至全英文目录后再试'
|
||||||
const dbPathPlaceholder = isMac
|
const dbPathPlaceholder = isMac
|
||||||
@@ -39,10 +40,19 @@ interface WelcomePageProps {
|
|||||||
|
|
||||||
const formatDbKeyFailureMessage = (error?: string, logs?: string[]): string => {
|
const formatDbKeyFailureMessage = (error?: string, logs?: string[]): string => {
|
||||||
const base = String(error || '自动获取密钥失败').trim()
|
const base = String(error || '自动获取密钥失败').trim()
|
||||||
|
const isInternalLine = (line: string): boolean => {
|
||||||
|
const lower = line.toLowerCase()
|
||||||
|
return lower.includes('xkey_helper')
|
||||||
|
|| lower.includes('[debug]')
|
||||||
|
|| lower.includes('breakpoint')
|
||||||
|
|| lower.includes('hook installed @')
|
||||||
|
|| lower.includes('scanner ')
|
||||||
|
}
|
||||||
const tailLogs = Array.isArray(logs)
|
const tailLogs = Array.isArray(logs)
|
||||||
? logs
|
? logs
|
||||||
.map(item => String(item || '').trim())
|
.map(item => String(item || '').trim())
|
||||||
.filter(Boolean)
|
.filter(item => Boolean(item) && !isInternalLine(item))
|
||||||
|
.map(item => item.length > 80 ? `${item.slice(0, 80)}...` : item)
|
||||||
.slice(-6)
|
.slice(-6)
|
||||||
: []
|
: []
|
||||||
if (tailLogs.length === 0) return base
|
if (tailLogs.length === 0) return base
|
||||||
@@ -117,6 +127,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
const [isImageStepAutoCompleted, setIsImageStepAutoCompleted] = useState(false)
|
const [isImageStepAutoCompleted, setIsImageStepAutoCompleted] = useState(false)
|
||||||
const [hasReacquiredDbKey, setHasReacquiredDbKey] = useState(!isAddAccountMode)
|
const [hasReacquiredDbKey, setHasReacquiredDbKey] = useState(!isAddAccountMode)
|
||||||
const [showDbKeyConfirm, setShowDbKeyConfirm] = useState(false)
|
const [showDbKeyConfirm, setShowDbKeyConfirm] = useState(false)
|
||||||
|
const [lastDbKeyError, setLastDbKeyError] = useState('')
|
||||||
const imagePrefetchAttemptRef = useRef<string>('')
|
const imagePrefetchAttemptRef = useRef<string>('')
|
||||||
|
|
||||||
// 安全相关 state
|
// 安全相关 state
|
||||||
@@ -476,6 +487,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
setShowDbKeyConfirm(false)
|
setShowDbKeyConfirm(false)
|
||||||
setIsFetchingDbKey(true)
|
setIsFetchingDbKey(true)
|
||||||
setError('')
|
setError('')
|
||||||
|
setLastDbKeyError('')
|
||||||
setIsManualStartPrompt(false)
|
setIsManualStartPrompt(false)
|
||||||
setDbKeyStatus('正在连接微信进程...')
|
setDbKeyStatus('正在连接微信进程...')
|
||||||
try {
|
try {
|
||||||
@@ -499,20 +511,29 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
) {
|
) {
|
||||||
setIsManualStartPrompt(true)
|
setIsManualStartPrompt(true)
|
||||||
setDbKeyStatus('需要手动启动微信')
|
setDbKeyStatus('需要手动启动微信')
|
||||||
|
setLastDbKeyError('')
|
||||||
} else {
|
} else {
|
||||||
if (result.error?.includes('尚未完成登录')) {
|
if (result.error?.includes('尚未完成登录')) {
|
||||||
setDbKeyStatus('请先在微信完成登录后重试')
|
setDbKeyStatus('请先在微信完成登录后重试')
|
||||||
}
|
}
|
||||||
setError(formatDbKeyFailureMessage(result.error, result.logs))
|
const failureMessage = formatDbKeyFailureMessage(result.error, result.logs)
|
||||||
|
setError(failureMessage)
|
||||||
|
setLastDbKeyError(failureMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(`自动获取密钥失败: ${e}`)
|
const failureMessage = `自动获取密钥失败: ${e}`
|
||||||
|
setError(failureMessage)
|
||||||
|
setLastDbKeyError(failureMessage)
|
||||||
} finally {
|
} finally {
|
||||||
setIsFetchingDbKey(false)
|
setIsFetchingDbKey(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openMacKeyFaq = () => {
|
||||||
|
void window.electronAPI.shell.openExternal(MAC_KEY_FAQ_URL)
|
||||||
|
}
|
||||||
|
|
||||||
const handleManualConfirm = async () => {
|
const handleManualConfirm = async () => {
|
||||||
setIsManualStartPrompt(false)
|
setIsManualStartPrompt(false)
|
||||||
handleAutoGetDbKey()
|
handleAutoGetDbKey()
|
||||||
@@ -1161,7 +1182,16 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="error-message">{error}</div>}
|
{error && (
|
||||||
|
<div className="error-message">
|
||||||
|
<div className="error-text">{error}</div>
|
||||||
|
{isMac && error === lastDbKeyError && (
|
||||||
|
<button type="button" className="error-link-btn" onClick={openMacKeyFaq}>
|
||||||
|
查看 macOS 获取密钥排障指引
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{currentStep.id === 'intro' && (
|
{currentStep.id === 'intro' && (
|
||||||
<div className="intro-footer">
|
<div className="intro-footer">
|
||||||
|
|||||||
Reference in New Issue
Block a user