mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
Merge remote-tracking branch 'upstream/dev' into feat/linux
This commit is contained in:
103
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
103
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
name: "报告 Bug"
|
||||||
|
description: "代码出现了非预期的问题、崩溃或报错"
|
||||||
|
title: "[Bug]: "
|
||||||
|
labels: ["type: bug", "status: needs info"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
请提供尽可能详细的信息,帮助我们快速定位和修复问题。
|
||||||
|
- type: checkboxes
|
||||||
|
id: pre-check
|
||||||
|
attributes:
|
||||||
|
label: 提交前确认
|
||||||
|
description: 请务必确认以下事项
|
||||||
|
options:
|
||||||
|
- label: 我已搜索过现有的 Issues,确认这不是重复问题
|
||||||
|
required: true
|
||||||
|
- label: 我使用的是最新版本
|
||||||
|
required: true
|
||||||
|
- label: 我已阅读过相关文档
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: severity
|
||||||
|
attributes:
|
||||||
|
label: 问题严重程度
|
||||||
|
description: 这个问题对你的使用造成了多大影响?
|
||||||
|
options:
|
||||||
|
- 严重崩溃或数据丢失(无法使用)
|
||||||
|
- 核心功能受影响(在下一个常规发布中必须修复)
|
||||||
|
- 边缘场景或轻微问题(等待空闲时修复)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: 问题描述
|
||||||
|
description: 清晰描述你遇到的问题,包括实际发生了什么
|
||||||
|
placeholder: 例如:当我点击发送按钮时,应用程序崩溃并显示白屏
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: reproduction
|
||||||
|
attributes:
|
||||||
|
label: 复现步骤
|
||||||
|
description: 提供详细的操作步骤,让我们能够重现这个问题
|
||||||
|
placeholder: |
|
||||||
|
1. 打开应用并登录账号
|
||||||
|
2. 进入聊天页面
|
||||||
|
3. 点击发送按钮
|
||||||
|
4. 观察到应用崩溃
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: expected-behavior
|
||||||
|
attributes:
|
||||||
|
label: 预期行为
|
||||||
|
description: 描述你期望的正确行为应该是什么样的
|
||||||
|
placeholder: 例如:点击发送按钮后,消息应该正常发送并显示在聊天窗口中
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: actual-behavior
|
||||||
|
attributes:
|
||||||
|
label: 实际行为
|
||||||
|
description: 描述实际发生的错误行为
|
||||||
|
placeholder: 例如:点击后应用直接崩溃,显示白屏
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: 错误日志或截图
|
||||||
|
description: 粘贴控制台错误信息、崩溃日志,或拖入截图
|
||||||
|
placeholder: 请粘贴完整的错误堆栈信息
|
||||||
|
render: shell
|
||||||
|
- type: input
|
||||||
|
id: os
|
||||||
|
attributes:
|
||||||
|
label: 操作系统
|
||||||
|
description: 例如:Windows 11、macOS 14.2、Ubuntu 22.04
|
||||||
|
placeholder: Windows 11
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: app-version
|
||||||
|
attributes:
|
||||||
|
label: 应用版本
|
||||||
|
description: 在关于页面或设置中查看版本号
|
||||||
|
placeholder: v1.2.3
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: architecture
|
||||||
|
attributes:
|
||||||
|
label: 系统架构
|
||||||
|
description: 例如:x64、arm64
|
||||||
|
placeholder: x64
|
||||||
|
- type: textarea
|
||||||
|
id: additional-context
|
||||||
|
attributes:
|
||||||
|
label: 补充信息
|
||||||
|
description: 其他可能有助于定位问题的信息
|
||||||
|
placeholder: 例如:这个问题是在某次更新后开始出现的,或者只在特定网络环境下出现
|
||||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name:🤔 找不到合适的模板?
|
||||||
|
url: https://t.me/weflow_cc
|
||||||
|
about: 如果你的问题不属于上述任何分类,请前往我们的 Telegram 频道与我们交流。
|
||||||
67
.github/ISSUE_TEMPLATE/docs.yml
vendored
Normal file
67
.github/ISSUE_TEMPLATE/docs.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
name: "文档反馈"
|
||||||
|
description: "文档存在错别字、描述不清晰或缺少必要的示例"
|
||||||
|
title: "[Docs]: "
|
||||||
|
labels: ["type: docs"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
优秀的文档和代码一样重要。感谢你帮助我们完善文档!
|
||||||
|
- type: dropdown
|
||||||
|
id: doc-type
|
||||||
|
attributes:
|
||||||
|
label: 文档类型
|
||||||
|
description: 问题出现在哪类文档中?
|
||||||
|
options:
|
||||||
|
- README 或项目说明
|
||||||
|
- 安装部署文档
|
||||||
|
- 使用教程
|
||||||
|
- API 文档
|
||||||
|
- 开发者文档
|
||||||
|
- 其他
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: doc-link
|
||||||
|
attributes:
|
||||||
|
label: 文档位置
|
||||||
|
description: 提供文档的 URL 或文件路径
|
||||||
|
placeholder: 例如:docs/installation.md 或 https://github.com/xxx/xxx/wiki/xxx
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: issue-type
|
||||||
|
attributes:
|
||||||
|
label: 问题类型
|
||||||
|
description: 文档存在什么问题?
|
||||||
|
options:
|
||||||
|
- 错别字或语法错误
|
||||||
|
- 内容过时或不准确
|
||||||
|
- 描述不清晰或有歧义
|
||||||
|
- 缺少必要的示例代码
|
||||||
|
- 缺少重要的说明或警告
|
||||||
|
- 链接失效或错误
|
||||||
|
- 其他
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: issue-desc
|
||||||
|
attributes:
|
||||||
|
label: 问题描述
|
||||||
|
description: 详细说明文档中存在的问题
|
||||||
|
placeholder: 例如:第 3 步中的命令拼写错误,应该是 "npm install" 而不是 "npm instal"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: suggestion
|
||||||
|
attributes:
|
||||||
|
label: 修改建议
|
||||||
|
description: 你认为应该如何修改?
|
||||||
|
placeholder: 例如:建议将"安装依赖"部分补充完整的命令示例,并说明不同操作系统的差异
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: 补充说明
|
||||||
|
description: 其他需要补充的信息
|
||||||
78
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
Normal file
78
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
name: "功能与体验优化"
|
||||||
|
description: "对现有的功能逻辑进行优化,或改进用户体验"
|
||||||
|
title: "[Enhancement]: "
|
||||||
|
labels: ["type: enhancement"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
持续优化是项目进步的动力!请告诉我们哪个现有功能可以做得更好。
|
||||||
|
- type: checkboxes
|
||||||
|
id: pre-check
|
||||||
|
attributes:
|
||||||
|
label: 提交前确认
|
||||||
|
options:
|
||||||
|
- label: 我已搜索过现有的 Issues,确认这个优化建议尚未被提出
|
||||||
|
required: true
|
||||||
|
- label: 这是对现有功能的改进,而不是全新功能
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: category
|
||||||
|
attributes:
|
||||||
|
label: 优化类别
|
||||||
|
description: 这个优化主要属于哪个方面?
|
||||||
|
options:
|
||||||
|
- 性能优化(速度、内存、资源占用)
|
||||||
|
- 交互体验(操作流程、界面布局)
|
||||||
|
- 视觉设计(样式、动画、美观度)
|
||||||
|
- 易用性(降低使用门槛、减少操作步骤)
|
||||||
|
- 稳定性(减少崩溃、提高可靠性)
|
||||||
|
- 其他
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: target
|
||||||
|
attributes:
|
||||||
|
label: 目标功能或模块
|
||||||
|
description: 你希望优化的具体功能或页面是哪个?
|
||||||
|
placeholder: 例如:聊天页面的消息加载、设置页面的布局、文件上传功能
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: current-behavior
|
||||||
|
attributes:
|
||||||
|
label: 当前表现
|
||||||
|
description: 描述当前功能的不足之处或存在的问题
|
||||||
|
placeholder: 例如:消息列表滚动时会出现明显卡顿,加载 100 条消息需要 3 秒
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: improvement
|
||||||
|
attributes:
|
||||||
|
label: 优化建议
|
||||||
|
description: 详细说明你的优化方案和预期效果
|
||||||
|
placeholder: 例如:建议使用虚拟滚动技术,只渲染可见区域的消息,预计可将加载时间缩短到 0.5 秒以内
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: benefits
|
||||||
|
attributes:
|
||||||
|
label: 优化收益
|
||||||
|
description: 这个优化会带来什么具体好处?
|
||||||
|
placeholder: 例如:提升 80% 的加载速度、减少 50% 的内存占用、降低用户操作步骤从 5 步到 2 步
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: impact
|
||||||
|
attributes:
|
||||||
|
label: 影响范围
|
||||||
|
description: 这个优化会影响哪些用户或场景?
|
||||||
|
placeholder: 例如:所有用户在查看历史消息时都会受益,尤其是群聊消息较多的场景
|
||||||
|
- type: checkboxes
|
||||||
|
id: contribution
|
||||||
|
attributes:
|
||||||
|
label: 参与贡献
|
||||||
|
options:
|
||||||
|
- label: 我愿意提交 Pull Request 来实现这个优化
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
71
.github/ISSUE_TEMPLATE/feature.yml
vendored
Normal file
71
.github/ISSUE_TEMPLATE/feature.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
name: "全新功能请求"
|
||||||
|
description: "提议一个目前项目中完全没有的新特性"
|
||||||
|
title: "[Feature]: "
|
||||||
|
labels: ["type: feature"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
感谢你为项目提供新想法!详细的需求描述能极大提高该功能被采纳的几率。
|
||||||
|
- type: checkboxes
|
||||||
|
id: pre-check
|
||||||
|
attributes:
|
||||||
|
label: 提交前确认
|
||||||
|
options:
|
||||||
|
- label: 我已搜索过现有的 Issues 和 Pull Requests,确认这个功能尚未被提出或实现
|
||||||
|
required: true
|
||||||
|
- label: 这是一个全新的功能,而不是对现有功能的改进
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: priority
|
||||||
|
attributes:
|
||||||
|
label: 功能优先级
|
||||||
|
description: 你认为这个功能有多重要?
|
||||||
|
options:
|
||||||
|
- 高优先级(核心功能缺失,严重影响使用体验)
|
||||||
|
- 中优先级(有助于提升使用体验)
|
||||||
|
- 低优先级(锦上添花的功能)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: 问题或痛点
|
||||||
|
description: 【为什么需要】你现在做某件事遇到了什么困难?缺少什么能力?
|
||||||
|
placeholder: 例如:目前无法批量导出聊天记录,每次只能手动复制单条消息,处理 100 条消息需要半小时
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
attributes:
|
||||||
|
label: 期望的解决方案
|
||||||
|
description: 【怎么实现】详细描述功能的操作流程、界面位置、可选参数等
|
||||||
|
placeholder: 例如:在聊天窗口右键菜单添加"导出记录",点击后弹窗可选时间范围、导出格式(TXT/JSON)、筛选用户,最后保存到本地
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: use-case
|
||||||
|
attributes:
|
||||||
|
label: 使用场景
|
||||||
|
description: 【什么时候用】你会在哪些具体情况下使用这个功能?
|
||||||
|
placeholder: 例如:每周五整理工作讨论记录;保存客户沟通记录作为合同依据;备份重要群聊内容
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: 替代方案
|
||||||
|
description: 你目前使用什么临时方案?或者有没有考虑过其他实现方式?
|
||||||
|
placeholder: 例如:目前只能手动截图或逐条复制粘贴
|
||||||
|
- type: textarea
|
||||||
|
id: reference
|
||||||
|
attributes:
|
||||||
|
label: 参考示例
|
||||||
|
description: 其他应用中是否有类似功能可以参考?
|
||||||
|
placeholder: 例如:微信的聊天记录导出功能、Telegram 的导出数据功能
|
||||||
|
- type: checkboxes
|
||||||
|
id: contribution
|
||||||
|
attributes:
|
||||||
|
label: 参与贡献
|
||||||
|
options:
|
||||||
|
- label: 我愿意提交 Pull Request 来实现这个功能
|
||||||
71
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
71
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
name: "使用答疑"
|
||||||
|
description: "关于如何配置、如何使用项目的求助"
|
||||||
|
title: "[Question]: "
|
||||||
|
labels: ["type: question"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
在提问之前,请确保你已经仔细阅读过我们的官方文档。
|
||||||
|
- type: checkboxes
|
||||||
|
id: pre-check
|
||||||
|
attributes:
|
||||||
|
label: 提交前确认
|
||||||
|
options:
|
||||||
|
- label: 我已阅读过相关文档
|
||||||
|
required: true
|
||||||
|
- label: 我已搜索过现有的 Issues,没有找到类似问题
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: question-type
|
||||||
|
attributes:
|
||||||
|
label: 问题类型
|
||||||
|
description: 你的问题属于哪个方面?
|
||||||
|
options:
|
||||||
|
- 安装部署问题
|
||||||
|
- 配置相关问题
|
||||||
|
- 功能使用问题
|
||||||
|
- API 调用问题
|
||||||
|
- 错误排查问题
|
||||||
|
- 其他
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: question
|
||||||
|
attributes:
|
||||||
|
label: 问题描述
|
||||||
|
description: 清晰描述你遇到的问题或疑问
|
||||||
|
placeholder: 例如:我在 Windows 系统上安装后无法启动应用,双击图标没有任何反应
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: attempts
|
||||||
|
attributes:
|
||||||
|
label: 已尝试的方法
|
||||||
|
description: 你已经尝试过哪些解决方法?
|
||||||
|
placeholder: 例如:我尝试过重新安装、以管理员身份运行、关闭防火墙,但问题依然存在
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: 运行环境
|
||||||
|
description: 提供你的系统环境信息
|
||||||
|
placeholder: |
|
||||||
|
操作系统:Windows 11
|
||||||
|
应用版本:v1.2.3
|
||||||
|
系统架构:x64
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: code-snippet
|
||||||
|
attributes:
|
||||||
|
label: 相关配置或代码
|
||||||
|
description: 如果涉及配置或代码问题,请粘贴相关内容
|
||||||
|
placeholder: 粘贴你的配置文件或代码片段
|
||||||
|
render: javascript
|
||||||
|
- type: textarea
|
||||||
|
id: screenshots
|
||||||
|
attributes:
|
||||||
|
label: 截图或日志
|
||||||
|
description: 如有必要,请提供截图或错误日志
|
||||||
@@ -234,6 +234,8 @@ class ChatService {
|
|||||||
// 缓存会话表信息,避免每次查询
|
// 缓存会话表信息,避免每次查询
|
||||||
private sessionTablesCache = new Map<string, Array<{ tableName: string; dbPath: string }>>()
|
private sessionTablesCache = new Map<string, Array<{ tableName: string; dbPath: string }>>()
|
||||||
private messageTableColumnsCache = new Map<string, { columns: Set<string>; updatedAt: number }>()
|
private messageTableColumnsCache = new Map<string, { columns: Set<string>; updatedAt: number }>()
|
||||||
|
private messageName2IdTableCache = new Map<string, string | null>()
|
||||||
|
private messageSenderIdCache = new Map<string, string | null>()
|
||||||
private readonly sessionTablesCacheTtl = 300000 // 5分钟
|
private readonly sessionTablesCacheTtl = 300000 // 5分钟
|
||||||
private readonly messageTableColumnsCacheTtlMs = 30 * 60 * 1000
|
private readonly messageTableColumnsCacheTtlMs = 30 * 60 * 1000
|
||||||
private sessionMessageCountCache = new Map<string, { count: number; updatedAt: number }>()
|
private sessionMessageCountCache = new Map<string, { count: number; updatedAt: number }>()
|
||||||
@@ -1990,6 +1992,62 @@ class ChatService {
|
|||||||
return [lowerRaw]
|
return [lowerRaw]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveMessageIsSend(rawIsSend: number | null, senderUsername?: string | null): {
|
||||||
|
isSend: number | null
|
||||||
|
selfMatched: boolean
|
||||||
|
correctedBySelfIdentity: boolean
|
||||||
|
} {
|
||||||
|
const normalizedRawIsSend = Number.isFinite(rawIsSend as number) ? rawIsSend : null
|
||||||
|
const senderKeys = this.buildIdentityKeys(String(senderUsername || ''))
|
||||||
|
if (senderKeys.length === 0) {
|
||||||
|
return {
|
||||||
|
isSend: normalizedRawIsSend,
|
||||||
|
selfMatched: false,
|
||||||
|
correctedBySelfIdentity: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const myWxid = String(this.configService.get('myWxid') || '').trim()
|
||||||
|
const selfKeys = this.buildIdentityKeys(myWxid)
|
||||||
|
if (selfKeys.length === 0) {
|
||||||
|
return {
|
||||||
|
isSend: normalizedRawIsSend,
|
||||||
|
selfMatched: false,
|
||||||
|
correctedBySelfIdentity: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selfMatched = senderKeys.some(senderKey =>
|
||||||
|
selfKeys.some(selfKey =>
|
||||||
|
senderKey === selfKey ||
|
||||||
|
senderKey.startsWith(selfKey + '_') ||
|
||||||
|
selfKey.startsWith(senderKey + '_')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (selfMatched && normalizedRawIsSend !== 1) {
|
||||||
|
return {
|
||||||
|
isSend: 1,
|
||||||
|
selfMatched: true,
|
||||||
|
correctedBySelfIdentity: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedRawIsSend === null) {
|
||||||
|
return {
|
||||||
|
isSend: selfMatched ? 1 : 0,
|
||||||
|
selfMatched,
|
||||||
|
correctedBySelfIdentity: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSend: normalizedRawIsSend,
|
||||||
|
selfMatched,
|
||||||
|
correctedBySelfIdentity: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private extractGroupMemberUsername(member: any): string {
|
private extractGroupMemberUsername(member: any): string {
|
||||||
if (!member) return ''
|
if (!member) return ''
|
||||||
if (typeof member === 'string') return member.trim()
|
if (typeof member === 'string') return member.trim()
|
||||||
@@ -3048,9 +3106,6 @@ class ChatService {
|
|||||||
|
|
||||||
private mapRowsToMessages(rows: Record<string, any>[]): Message[] {
|
private mapRowsToMessages(rows: Record<string, any>[]): Message[] {
|
||||||
const myWxid = this.configService.get('myWxid')
|
const myWxid = this.configService.get('myWxid')
|
||||||
const cleanedWxid = myWxid ? this.cleanAccountDirName(myWxid) : null
|
|
||||||
const myWxidLower = myWxid ? myWxid.toLowerCase() : null
|
|
||||||
const cleanedWxidLower = cleanedWxid ? cleanedWxid.toLowerCase() : null
|
|
||||||
|
|
||||||
const messages: Message[] = []
|
const messages: Message[] = []
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -3075,30 +3130,14 @@ class ChatService {
|
|||||||
const content = this.decodeMessageContent(rawMessageContent, rawCompressContent);
|
const content = this.decodeMessageContent(rawMessageContent, rawCompressContent);
|
||||||
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1)
|
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1)
|
||||||
const isSendRaw = this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'])
|
const isSendRaw = this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'])
|
||||||
let isSend = isSendRaw === null ? null : parseInt(isSendRaw, 10)
|
const parsedRawIsSend = isSendRaw === null ? null : parseInt(isSendRaw, 10)
|
||||||
const senderUsername = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username'])
|
const senderUsername = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username'])
|
||||||
|| this.extractSenderUsernameFromContent(content)
|
|| this.extractSenderUsernameFromContent(content)
|
||||||
|| null
|
|| null
|
||||||
|
const { isSend } = this.resolveMessageIsSend(parsedRawIsSend, senderUsername)
|
||||||
const createTime = this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0)
|
const createTime = this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0)
|
||||||
|
|
||||||
if (senderUsername && (myWxidLower || cleanedWxidLower)) {
|
if (senderUsername && !myWxid) {
|
||||||
const senderLower = String(senderUsername).toLowerCase()
|
|
||||||
const expectedIsSend = (
|
|
||||||
senderLower === myWxidLower ||
|
|
||||||
senderLower === cleanedWxidLower ||
|
|
||||||
// 兼容非 wxid 开头的账号(如果文件夹名带后缀,如 custom_backup,而 sender 是 custom)
|
|
||||||
(myWxidLower && myWxidLower.startsWith(senderLower + '_')) ||
|
|
||||||
(cleanedWxidLower && cleanedWxidLower.startsWith(senderLower + '_'))
|
|
||||||
) ? 1 : 0
|
|
||||||
if (isSend === null) {
|
|
||||||
isSend = expectedIsSend
|
|
||||||
// [DEBUG] Issue #34: 记录 isSend 推断过程
|
|
||||||
if (expectedIsSend === 0 && localType === 1) {
|
|
||||||
// 仅在被判为接收且是文本消息时记录,避免刷屏
|
|
||||||
//
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (senderUsername && !myWxid) {
|
|
||||||
// [DEBUG] Issue #34: 未配置 myWxid,无法判断是否发送
|
// [DEBUG] Issue #34: 未配置 myWxid,无法判断是否发送
|
||||||
if (messages.length < 5) {
|
if (messages.length < 5) {
|
||||||
console.warn(`[ChatService] Warning: myWxid not set. Cannot determine if message is sent by me. sender=${senderUsername}`)
|
console.warn(`[ChatService] Warning: myWxid not set. Cannot determine if message is sent by me. sender=${senderUsername}`)
|
||||||
@@ -4421,6 +4460,75 @@ class ChatService {
|
|||||||
return result.rows[0]?.name || null
|
return result.rows[0]?.name || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveMessageName2IdTableName(dbPath: string): Promise<string | null> {
|
||||||
|
const normalizedDbPath = String(dbPath || '').trim()
|
||||||
|
if (!normalizedDbPath) return null
|
||||||
|
if (this.messageName2IdTableCache.has(normalizedDbPath)) {
|
||||||
|
return this.messageName2IdTableCache.get(normalizedDbPath) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await wcdbService.execQuery(
|
||||||
|
'message',
|
||||||
|
normalizedDbPath,
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Name2Id%' ORDER BY name DESC LIMIT 1"
|
||||||
|
)
|
||||||
|
const tableName = result.success && result.rows && result.rows.length > 0
|
||||||
|
? String(result.rows[0]?.name || '').trim() || null
|
||||||
|
: null
|
||||||
|
this.messageName2IdTableCache.set(normalizedDbPath, tableName)
|
||||||
|
return tableName
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveMessageSenderUsernameById(dbPath: string, senderId: unknown): Promise<string | null> {
|
||||||
|
const normalizedDbPath = String(dbPath || '').trim()
|
||||||
|
const numericSenderId = Number.parseInt(String(senderId ?? '').trim(), 10)
|
||||||
|
if (!normalizedDbPath || !Number.isFinite(numericSenderId) || numericSenderId <= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `${normalizedDbPath}::${numericSenderId}`
|
||||||
|
if (this.messageSenderIdCache.has(cacheKey)) {
|
||||||
|
return this.messageSenderIdCache.get(cacheKey) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const name2IdTable = await this.resolveMessageName2IdTableName(normalizedDbPath)
|
||||||
|
if (!name2IdTable) {
|
||||||
|
this.messageSenderIdCache.set(cacheKey, null)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapedTableName = String(name2IdTable).replace(/"/g, '""')
|
||||||
|
const result = await wcdbService.execQuery(
|
||||||
|
'message',
|
||||||
|
normalizedDbPath,
|
||||||
|
`SELECT user_name FROM "${escapedTableName}" WHERE rowid = ${numericSenderId} LIMIT 1`
|
||||||
|
)
|
||||||
|
const username = result.success && result.rows && result.rows.length > 0
|
||||||
|
? String(result.rows[0]?.user_name || result.rows[0]?.userName || '').trim() || null
|
||||||
|
: null
|
||||||
|
this.messageSenderIdCache.set(cacheKey, username)
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveSenderUsernameForMessageRow(
|
||||||
|
row: Record<string, any>,
|
||||||
|
rawContent: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const directSender = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username'])
|
||||||
|
|| this.extractSenderUsernameFromContent(rawContent)
|
||||||
|
if (directSender) {
|
||||||
|
return directSender
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbPath = this.getRowField(row, ['db_path', 'dbPath', '_db_path'])
|
||||||
|
const realSenderId = this.getRowField(row, ['real_sender_id', 'realSenderId'])
|
||||||
|
if (!dbPath || realSenderId === null || realSenderId === undefined || String(realSenderId).trim() === '') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.resolveMessageSenderUsernameById(String(dbPath), realSenderId)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断是否像 wxid
|
* 判断是否像 wxid
|
||||||
*/
|
*/
|
||||||
@@ -6690,7 +6798,7 @@ class ChatService {
|
|||||||
db_path: dbPath,
|
db_path: dbPath,
|
||||||
table_name: tableName
|
table_name: tableName
|
||||||
}
|
}
|
||||||
const message = this.parseMessage(row)
|
const message = await this.parseMessage(row, { source: 'detail', sessionId })
|
||||||
|
|
||||||
if (message.localId !== 0) {
|
if (message.localId !== 0) {
|
||||||
return { success: true, message }
|
return { success: true, message }
|
||||||
@@ -6711,7 +6819,45 @@ class ChatService {
|
|||||||
if (!result.success || !result.messages) {
|
if (!result.success || !result.messages) {
|
||||||
return { success: false, error: result.error || '搜索失败' }
|
return { success: false, error: result.error || '搜索失败' }
|
||||||
}
|
}
|
||||||
const messages = result.messages.map((row: any) => this.parseMessage(row)).filter(Boolean) as Message[]
|
const messages: Message[] = []
|
||||||
|
const isGroupSearch = Boolean(String(sessionId || '').trim().endsWith('@chatroom'))
|
||||||
|
|
||||||
|
for (const row of result.messages) {
|
||||||
|
let message = await this.parseMessage(row, { source: 'search', sessionId })
|
||||||
|
const needsDetailHydration = isGroupSearch &&
|
||||||
|
Boolean(sessionId) &&
|
||||||
|
message.localId > 0 &&
|
||||||
|
(!message.senderUsername || message.isSend === null)
|
||||||
|
|
||||||
|
if (needsDetailHydration && sessionId) {
|
||||||
|
const detail = await this.getMessageById(sessionId, message.localId)
|
||||||
|
if (detail.success && detail.message) {
|
||||||
|
message = {
|
||||||
|
...message,
|
||||||
|
...detail.message,
|
||||||
|
parsedContent: message.parsedContent || detail.message.parsedContent,
|
||||||
|
rawContent: message.rawContent || detail.message.rawContent,
|
||||||
|
content: message.content || detail.message.content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isGroupSearch && (needsDetailHydration || message.isSend === 1)) {
|
||||||
|
console.info('[ChatService][GroupSearchHydratedHit]', {
|
||||||
|
sessionId,
|
||||||
|
localId: message.localId,
|
||||||
|
senderUsername: message.senderUsername,
|
||||||
|
isSend: message.isSend,
|
||||||
|
senderDisplayName: message.senderDisplayName,
|
||||||
|
senderAvatarUrl: message.senderAvatarUrl,
|
||||||
|
usedDetailHydration: needsDetailHydration,
|
||||||
|
parsedContent: message.parsedContent
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push(message)
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true, messages }
|
return { success: true, messages }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('ChatService: searchMessages 失败:', e)
|
console.error('ChatService: searchMessages 失败:', e)
|
||||||
@@ -6719,7 +6865,7 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseMessage(row: any): Message {
|
private async parseMessage(row: any, options?: { source?: 'search' | 'detail'; sessionId?: string }): Promise<Message> {
|
||||||
const sourceInfo = this.getMessageSourceInfo(row)
|
const sourceInfo = this.getMessageSourceInfo(row)
|
||||||
const rawContent = this.decodeMessageContent(
|
const rawContent = this.decodeMessageContent(
|
||||||
this.getRowField(row, [
|
this.getRowField(row, [
|
||||||
@@ -6746,9 +6892,9 @@ class ChatService {
|
|||||||
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0)
|
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0)
|
||||||
const createTime = this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0)
|
const createTime = this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0)
|
||||||
const sortSeq = this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime)
|
const sortSeq = this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime)
|
||||||
const senderUsername = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username'])
|
const rawIsSend = this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'])
|
||||||
|| this.extractSenderUsernameFromContent(rawContent)
|
const senderUsername = await this.resolveSenderUsernameForMessageRow(row, rawContent)
|
||||||
|| null
|
const sendState = this.resolveMessageIsSend(rawIsSend === null ? null : parseInt(rawIsSend, 10), senderUsername)
|
||||||
const msg: Message = {
|
const msg: Message = {
|
||||||
messageKey: this.buildMessageKey({
|
messageKey: this.buildMessageKey({
|
||||||
localId,
|
localId,
|
||||||
@@ -6764,7 +6910,7 @@ class ChatService {
|
|||||||
localType,
|
localType,
|
||||||
createTime,
|
createTime,
|
||||||
sortSeq,
|
sortSeq,
|
||||||
isSend: this.getRowInt(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'], 0),
|
isSend: sendState.isSend,
|
||||||
senderUsername,
|
senderUsername,
|
||||||
rawContent: rawContent,
|
rawContent: rawContent,
|
||||||
content: rawContent, // 添加原始内容供视频MD5解析使用
|
content: rawContent, // 添加原始内容供视频MD5解析使用
|
||||||
@@ -6785,6 +6931,19 @@ class ChatService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options?.source === 'search' && String(options.sessionId || '').endsWith('@chatroom') && sendState.selfMatched) {
|
||||||
|
console.info('[ChatService][GroupSearchSelfHit]', {
|
||||||
|
sessionId: options.sessionId,
|
||||||
|
localId,
|
||||||
|
createTime,
|
||||||
|
senderUsername,
|
||||||
|
rawIsSend,
|
||||||
|
resolvedIsSend: sendState.isSend,
|
||||||
|
correctedBySelfIdentity: sendState.correctedBySelfIdentity,
|
||||||
|
rowKeys: Object.keys(row)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 图片/语音解析逻辑 (简化示例,实际应调用现有解析方法)
|
// 图片/语音解析逻辑 (简化示例,实际应调用现有解析方法)
|
||||||
if (msg.localType === 3) { // Image
|
if (msg.localType === 3) { // Image
|
||||||
const imgInfo = this.parseImageInfo(rawContent)
|
const imgInfo = this.parseImageInfo(rawContent)
|
||||||
|
|||||||
@@ -116,6 +116,40 @@ function resolveSearchSenderUsernameFallback(value?: string | null): string | un
|
|||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildSearchIdentityCandidates(value?: string | null): string[] {
|
||||||
|
const normalized = normalizeSearchIdentityText(value)
|
||||||
|
if (!normalized) return []
|
||||||
|
const lower = normalized.toLowerCase()
|
||||||
|
const candidates = new Set<string>([lower])
|
||||||
|
if (lower.startsWith('wxid_')) {
|
||||||
|
const match = lower.match(/^(wxid_[^_]+)/i)
|
||||||
|
if (match?.[1]) {
|
||||||
|
candidates.add(match[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...candidates]
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentUserSearchIdentity(
|
||||||
|
senderUsername?: string | null,
|
||||||
|
myWxid?: string | null
|
||||||
|
): boolean {
|
||||||
|
const senderCandidates = buildSearchIdentityCandidates(senderUsername)
|
||||||
|
const selfCandidates = buildSearchIdentityCandidates(myWxid)
|
||||||
|
if (senderCandidates.length === 0 || selfCandidates.length === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sender of senderCandidates) {
|
||||||
|
for (const self of selfCandidates) {
|
||||||
|
if (sender === self) return true
|
||||||
|
if (sender.startsWith(self + '_')) return true
|
||||||
|
if (self.startsWith(sender + '_')) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
interface XmlField {
|
interface XmlField {
|
||||||
key: string;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -2764,6 +2798,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
const {
|
const {
|
||||||
normalizedSessionId,
|
normalizedSessionId,
|
||||||
isDirectSearchSession,
|
isDirectSearchSession,
|
||||||
|
isGroupSearchSession,
|
||||||
resolvedSessionDisplayName,
|
resolvedSessionDisplayName,
|
||||||
resolvedSessionAvatarUrl
|
resolvedSessionAvatarUrl
|
||||||
} = resolveSearchSessionContext(sessionId)
|
} = resolveSearchSessionContext(sessionId)
|
||||||
@@ -2771,6 +2806,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
|
|
||||||
return sortedMessages.map((message) => {
|
return sortedMessages.map((message) => {
|
||||||
const senderUsername = normalizeSearchIdentityText(message.senderUsername) || message.senderUsername
|
const senderUsername = normalizeSearchIdentityText(message.senderUsername) || message.senderUsername
|
||||||
|
const inferredSelfFromSender = isGroupSearchSession && isCurrentUserSearchIdentity(senderUsername, myWxid)
|
||||||
const senderDisplayName = resolveSearchSenderDisplayName(
|
const senderDisplayName = resolveSearchSenderDisplayName(
|
||||||
message.senderDisplayName,
|
message.senderDisplayName,
|
||||||
senderUsername,
|
senderUsername,
|
||||||
@@ -2778,7 +2814,8 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
)
|
)
|
||||||
const senderUsernameFallback = resolveSearchSenderUsernameFallback(senderUsername)
|
const senderUsernameFallback = resolveSearchSenderUsernameFallback(senderUsername)
|
||||||
const senderAvatarUrl = normalizeSearchAvatarUrl(message.senderAvatarUrl)
|
const senderAvatarUrl = normalizeSearchAvatarUrl(message.senderAvatarUrl)
|
||||||
const nextSenderDisplayName = message.isSend === 1
|
const nextIsSend = inferredSelfFromSender ? 1 : message.isSend
|
||||||
|
const nextSenderDisplayName = nextIsSend === 1
|
||||||
? (senderDisplayName || '我')
|
? (senderDisplayName || '我')
|
||||||
: (
|
: (
|
||||||
senderDisplayName ||
|
senderDisplayName ||
|
||||||
@@ -2787,12 +2824,29 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
(isDirectSearchSession ? resolvedSessionUsernameFallback : undefined) ||
|
(isDirectSearchSession ? resolvedSessionUsernameFallback : undefined) ||
|
||||||
'未知'
|
'未知'
|
||||||
)
|
)
|
||||||
const nextSenderAvatarUrl = message.isSend === 1
|
const nextSenderAvatarUrl = nextIsSend === 1
|
||||||
? (senderAvatarUrl || myAvatarUrl)
|
? (senderAvatarUrl || myAvatarUrl)
|
||||||
: (senderAvatarUrl || (isDirectSearchSession ? resolvedSessionAvatarUrl : undefined))
|
: (senderAvatarUrl || (isDirectSearchSession ? resolvedSessionAvatarUrl : undefined))
|
||||||
|
|
||||||
|
if (inferredSelfFromSender) {
|
||||||
|
console.info('[InSessionSearch][GroupSelfHit][hydrate]', {
|
||||||
|
sessionId: normalizedSessionId,
|
||||||
|
localId: message.localId,
|
||||||
|
senderUsername,
|
||||||
|
rawIsSend: message.isSend,
|
||||||
|
nextIsSend,
|
||||||
|
rawSenderDisplayName: message.senderDisplayName,
|
||||||
|
nextSenderDisplayName,
|
||||||
|
rawSenderAvatarUrl: message.senderAvatarUrl,
|
||||||
|
nextSenderAvatarUrl,
|
||||||
|
myWxid,
|
||||||
|
hasMyAvatarUrl: Boolean(myAvatarUrl)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
senderUsername === message.senderUsername &&
|
senderUsername === message.senderUsername &&
|
||||||
|
nextIsSend === message.isSend &&
|
||||||
nextSenderDisplayName === message.senderDisplayName &&
|
nextSenderDisplayName === message.senderDisplayName &&
|
||||||
nextSenderAvatarUrl === message.senderAvatarUrl
|
nextSenderAvatarUrl === message.senderAvatarUrl
|
||||||
) {
|
) {
|
||||||
@@ -2801,12 +2855,13 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...message,
|
...message,
|
||||||
|
isSend: nextIsSend,
|
||||||
senderUsername,
|
senderUsername,
|
||||||
senderDisplayName: nextSenderDisplayName,
|
senderDisplayName: nextSenderDisplayName,
|
||||||
senderAvatarUrl: nextSenderAvatarUrl
|
senderAvatarUrl: nextSenderAvatarUrl
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [currentSessionId, myAvatarUrl, resolveSearchSessionContext])
|
}, [currentSessionId, myAvatarUrl, myWxid, resolveSearchSessionContext])
|
||||||
|
|
||||||
const enrichMessagesWithSenderProfiles = useCallback(async (rawMessages: Message[], sessionId?: string) => {
|
const enrichMessagesWithSenderProfiles = useCallback(async (rawMessages: Message[], sessionId?: string) => {
|
||||||
let messages = hydrateInSessionSearchResults(rawMessages, sessionId)
|
let messages = hydrateInSessionSearchResults(rawMessages, sessionId)
|
||||||
@@ -2962,6 +3017,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
return messages.map((message) => {
|
return messages.map((message) => {
|
||||||
const sender = normalizeSearchIdentityText(message.senderUsername)
|
const sender = normalizeSearchIdentityText(message.senderUsername)
|
||||||
const profile = sender ? profileMap.get(sender) : undefined
|
const profile = sender ? profileMap.get(sender) : undefined
|
||||||
|
const inferredSelfFromSender = isGroupSearchSession && isCurrentUserSearchIdentity(sender, myWxid)
|
||||||
const profileDisplayName = resolveSearchSenderDisplayName(
|
const profileDisplayName = resolveSearchSenderDisplayName(
|
||||||
profile?.displayName,
|
profile?.displayName,
|
||||||
sender,
|
sender,
|
||||||
@@ -2975,7 +3031,8 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
const senderUsernameFallback = resolveSearchSenderUsernameFallback(sender)
|
const senderUsernameFallback = resolveSearchSenderUsernameFallback(sender)
|
||||||
const sessionUsernameFallback = resolveSearchSenderUsernameFallback(normalizedSessionId)
|
const sessionUsernameFallback = resolveSearchSenderUsernameFallback(normalizedSessionId)
|
||||||
const currentSenderAvatarUrl = normalizeSearchAvatarUrl(message.senderAvatarUrl)
|
const currentSenderAvatarUrl = normalizeSearchAvatarUrl(message.senderAvatarUrl)
|
||||||
const nextSenderDisplayName = message.isSend === 1
|
const nextIsSend = inferredSelfFromSender ? 1 : message.isSend
|
||||||
|
const nextSenderDisplayName = nextIsSend === 1
|
||||||
? (currentSenderDisplayName || profileDisplayName || '我')
|
? (currentSenderDisplayName || profileDisplayName || '我')
|
||||||
: (
|
: (
|
||||||
profileDisplayName ||
|
profileDisplayName ||
|
||||||
@@ -2985,7 +3042,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
(isDirectSearchSession ? sessionUsernameFallback : undefined) ||
|
(isDirectSearchSession ? sessionUsernameFallback : undefined) ||
|
||||||
'未知'
|
'未知'
|
||||||
)
|
)
|
||||||
const nextSenderAvatarUrl = message.isSend === 1
|
const nextSenderAvatarUrl = nextIsSend === 1
|
||||||
? (currentSenderAvatarUrl || myAvatarUrl || normalizeSearchAvatarUrl(profile?.avatarUrl))
|
? (currentSenderAvatarUrl || myAvatarUrl || normalizeSearchAvatarUrl(profile?.avatarUrl))
|
||||||
: (
|
: (
|
||||||
currentSenderAvatarUrl ||
|
currentSenderAvatarUrl ||
|
||||||
@@ -2993,8 +3050,27 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
(isDirectSearchSession ? resolvedSessionAvatarUrl : undefined)
|
(isDirectSearchSession ? resolvedSessionAvatarUrl : undefined)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (inferredSelfFromSender) {
|
||||||
|
console.info('[InSessionSearch][GroupSelfHit][enrich]', {
|
||||||
|
sessionId: normalizedSessionId,
|
||||||
|
localId: message.localId,
|
||||||
|
senderUsername: sender,
|
||||||
|
rawIsSend: message.isSend,
|
||||||
|
nextIsSend,
|
||||||
|
profileDisplayName,
|
||||||
|
currentSenderDisplayName,
|
||||||
|
nextSenderDisplayName,
|
||||||
|
profileAvatarUrl: normalizeSearchAvatarUrl(profile?.avatarUrl),
|
||||||
|
currentSenderAvatarUrl,
|
||||||
|
nextSenderAvatarUrl,
|
||||||
|
myWxid,
|
||||||
|
hasMyAvatarUrl: Boolean(myAvatarUrl)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
sender === message.senderUsername &&
|
sender === message.senderUsername &&
|
||||||
|
nextIsSend === message.isSend &&
|
||||||
nextSenderDisplayName === message.senderDisplayName &&
|
nextSenderDisplayName === message.senderDisplayName &&
|
||||||
nextSenderAvatarUrl === message.senderAvatarUrl
|
nextSenderAvatarUrl === message.senderAvatarUrl
|
||||||
) {
|
) {
|
||||||
@@ -3003,6 +3079,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...message,
|
...message,
|
||||||
|
isSend: nextIsSend,
|
||||||
senderUsername: sender || message.senderUsername,
|
senderUsername: sender || message.senderUsername,
|
||||||
senderDisplayName: nextSenderDisplayName,
|
senderDisplayName: nextSenderDisplayName,
|
||||||
senderAvatarUrl: nextSenderAvatarUrl
|
senderAvatarUrl: nextSenderAvatarUrl
|
||||||
@@ -3012,6 +3089,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
currentSessionId,
|
currentSessionId,
|
||||||
hydrateInSessionSearchResults,
|
hydrateInSessionSearchResults,
|
||||||
myAvatarUrl,
|
myAvatarUrl,
|
||||||
|
myWxid,
|
||||||
resolveSearchSessionContext
|
resolveSearchSessionContext
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -1596,6 +1596,7 @@ function ExportPage() {
|
|||||||
const sessionMutualFriendsRunIdRef = useRef(0)
|
const sessionMutualFriendsRunIdRef = useRef(0)
|
||||||
const sessionMutualFriendsWorkerRunningRef = useRef(false)
|
const sessionMutualFriendsWorkerRunningRef = useRef(false)
|
||||||
const sessionMutualFriendsBackgroundFeedTimerRef = useRef<number | null>(null)
|
const sessionMutualFriendsBackgroundFeedTimerRef = useRef<number | null>(null)
|
||||||
|
const sessionMutualFriendsPersistTimerRef = useRef<number | null>(null)
|
||||||
const sessionMutualFriendsVisibleRangeRef = useRef<{ startIndex: number; endIndex: number }>({
|
const sessionMutualFriendsVisibleRangeRef = useRef<{ startIndex: number; endIndex: number }>({
|
||||||
startIndex: 0,
|
startIndex: 0,
|
||||||
endIndex: -1
|
endIndex: -1
|
||||||
@@ -2748,8 +2749,32 @@ function ExportPage() {
|
|||||||
window.clearTimeout(sessionMutualFriendsBackgroundFeedTimerRef.current)
|
window.clearTimeout(sessionMutualFriendsBackgroundFeedTimerRef.current)
|
||||||
sessionMutualFriendsBackgroundFeedTimerRef.current = null
|
sessionMutualFriendsBackgroundFeedTimerRef.current = null
|
||||||
}
|
}
|
||||||
|
if (sessionMutualFriendsPersistTimerRef.current) {
|
||||||
|
window.clearTimeout(sessionMutualFriendsPersistTimerRef.current)
|
||||||
|
sessionMutualFriendsPersistTimerRef.current = null
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const flushSessionMutualFriendsCache = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const scopeKey = await ensureExportCacheScope()
|
||||||
|
await configService.setExportSessionMutualFriendsCache(
|
||||||
|
scopeKey,
|
||||||
|
sessionMutualFriendsDirectMetricsRef.current
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('写入导出页共同好友缓存失败:', error)
|
||||||
|
}
|
||||||
|
}, [ensureExportCacheScope])
|
||||||
|
|
||||||
|
const scheduleFlushSessionMutualFriendsCache = useCallback(() => {
|
||||||
|
if (sessionMutualFriendsPersistTimerRef.current) return
|
||||||
|
sessionMutualFriendsPersistTimerRef.current = window.setTimeout(() => {
|
||||||
|
sessionMutualFriendsPersistTimerRef.current = null
|
||||||
|
void flushSessionMutualFriendsCache()
|
||||||
|
}, SESSION_MEDIA_METRIC_CACHE_FLUSH_DELAY_MS)
|
||||||
|
}, [flushSessionMutualFriendsCache])
|
||||||
|
|
||||||
const isSessionMutualFriendsReady = useCallback((sessionId: string): boolean => {
|
const isSessionMutualFriendsReady = useCallback((sessionId: string): boolean => {
|
||||||
if (!sessionId) return true
|
if (!sessionId) return true
|
||||||
if (sessionMutualFriendsReadySetRef.current.has(sessionId)) return true
|
if (sessionMutualFriendsReadySetRef.current.has(sessionId)) return true
|
||||||
@@ -2879,10 +2904,35 @@ function ExportPage() {
|
|||||||
}
|
}
|
||||||
}, [getSessionMutualFriendProfile])
|
}, [getSessionMutualFriendProfile])
|
||||||
|
|
||||||
|
const rebuildSessionMutualFriendsStateFromDirectMetrics = useCallback((sessionIds?: string[]) => {
|
||||||
|
const targets = Array.isArray(sessionIds) && sessionIds.length > 0
|
||||||
|
? sessionIds
|
||||||
|
: Object.keys(sessionMutualFriendsDirectMetricsRef.current)
|
||||||
|
const nextMetrics: Record<string, SessionMutualFriendsMetric> = {}
|
||||||
|
const readyIds: string[] = []
|
||||||
|
for (const sessionIdRaw of targets) {
|
||||||
|
const sessionId = String(sessionIdRaw || '').trim()
|
||||||
|
if (!sessionId) continue
|
||||||
|
const rebuilt = rebuildSessionMutualFriendsMetric(sessionId)
|
||||||
|
if (!rebuilt) continue
|
||||||
|
nextMetrics[sessionId] = rebuilt
|
||||||
|
readyIds.push(sessionId)
|
||||||
|
}
|
||||||
|
sessionMutualFriendsMetricsRef.current = nextMetrics
|
||||||
|
setSessionMutualFriendsMetrics(nextMetrics)
|
||||||
|
if (readyIds.length > 0) {
|
||||||
|
for (const sessionId of readyIds) {
|
||||||
|
sessionMutualFriendsReadySetRef.current.add(sessionId)
|
||||||
|
}
|
||||||
|
patchSessionLoadTraceStage(readyIds, 'mutualFriends', 'done')
|
||||||
|
}
|
||||||
|
}, [patchSessionLoadTraceStage, rebuildSessionMutualFriendsMetric])
|
||||||
|
|
||||||
const applySessionMutualFriendsMetric = useCallback((sessionId: string, directMetric: SessionMutualFriendsMetric) => {
|
const applySessionMutualFriendsMetric = useCallback((sessionId: string, directMetric: SessionMutualFriendsMetric) => {
|
||||||
const normalizedSessionId = String(sessionId || '').trim()
|
const normalizedSessionId = String(sessionId || '').trim()
|
||||||
if (!normalizedSessionId) return
|
if (!normalizedSessionId) return
|
||||||
sessionMutualFriendsDirectMetricsRef.current[normalizedSessionId] = directMetric
|
sessionMutualFriendsDirectMetricsRef.current[normalizedSessionId] = directMetric
|
||||||
|
scheduleFlushSessionMutualFriendsCache()
|
||||||
|
|
||||||
const impactedSessionIds = new Set<string>([normalizedSessionId])
|
const impactedSessionIds = new Set<string>([normalizedSessionId])
|
||||||
const allSessionIds = sessionsRef.current
|
const allSessionIds = sessionsRef.current
|
||||||
@@ -2912,7 +2962,7 @@ function ExportPage() {
|
|||||||
}
|
}
|
||||||
return changed ? next : prev
|
return changed ? next : prev
|
||||||
})
|
})
|
||||||
}, [getSessionMutualFriendProfile, rebuildSessionMutualFriendsMetric])
|
}, [getSessionMutualFriendProfile, rebuildSessionMutualFriendsMetric, scheduleFlushSessionMutualFriendsCache])
|
||||||
|
|
||||||
const isSessionMediaMetricReady = useCallback((sessionId: string): boolean => {
|
const isSessionMediaMetricReady = useCallback((sessionId: string): boolean => {
|
||||||
if (!sessionId) return true
|
if (!sessionId) return true
|
||||||
@@ -3339,11 +3389,13 @@ function ExportPage() {
|
|||||||
const [
|
const [
|
||||||
cachedContactsPayload,
|
cachedContactsPayload,
|
||||||
cachedMessageCountsPayload,
|
cachedMessageCountsPayload,
|
||||||
cachedContentMetricsPayload
|
cachedContentMetricsPayload,
|
||||||
|
cachedMutualFriendsPayload
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
loadContactsCaches(scopeKey),
|
loadContactsCaches(scopeKey),
|
||||||
configService.getExportSessionMessageCountCache(scopeKey),
|
configService.getExportSessionMessageCountCache(scopeKey),
|
||||||
configService.getExportSessionContentMetricCache(scopeKey)
|
configService.getExportSessionContentMetricCache(scopeKey),
|
||||||
|
configService.getExportSessionMutualFriendsCache(scopeKey)
|
||||||
])
|
])
|
||||||
if (isStale()) return
|
if (isStale()) return
|
||||||
|
|
||||||
@@ -3411,6 +3463,15 @@ function ExportPage() {
|
|||||||
if (cachedContentMetricReadySessionIds.length > 0) {
|
if (cachedContentMetricReadySessionIds.length > 0) {
|
||||||
patchSessionLoadTraceStage(cachedContentMetricReadySessionIds, 'mediaMetrics', 'done')
|
patchSessionLoadTraceStage(cachedContentMetricReadySessionIds, 'mediaMetrics', 'done')
|
||||||
}
|
}
|
||||||
|
const cachedMutualFriendDirectMetrics = Object.entries(cachedMutualFriendsPayload?.metrics || {}).reduce<Record<string, SessionMutualFriendsMetric>>((acc, [sessionIdRaw, metricRaw]) => {
|
||||||
|
const sessionId = String(sessionIdRaw || '').trim()
|
||||||
|
if (!exportableSessionIdSet.has(sessionId) || !isSingleContactSession(sessionId)) return acc
|
||||||
|
const metric = metricRaw as SessionMutualFriendsMetric | undefined
|
||||||
|
if (!metric || !Array.isArray(metric.items) || !Number.isFinite(metric.count)) return acc
|
||||||
|
acc[sessionId] = metric
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
const cachedMutualFriendSessionIds = Object.keys(cachedMutualFriendDirectMetrics)
|
||||||
|
|
||||||
if (isStale()) return
|
if (isStale()) return
|
||||||
if (Object.keys(cachedMessageCounts).length > 0) {
|
if (Object.keys(cachedMessageCounts).length > 0) {
|
||||||
@@ -3422,6 +3483,13 @@ function ExportPage() {
|
|||||||
if (Object.keys(cachedContentMetrics).length > 0) {
|
if (Object.keys(cachedContentMetrics).length > 0) {
|
||||||
mergeSessionContentMetrics(cachedContentMetrics)
|
mergeSessionContentMetrics(cachedContentMetrics)
|
||||||
}
|
}
|
||||||
|
if (cachedMutualFriendSessionIds.length > 0) {
|
||||||
|
sessionMutualFriendsDirectMetricsRef.current = cachedMutualFriendDirectMetrics
|
||||||
|
rebuildSessionMutualFriendsStateFromDirectMetrics(cachedMutualFriendSessionIds)
|
||||||
|
} else {
|
||||||
|
sessionMutualFriendsMetricsRef.current = {}
|
||||||
|
setSessionMutualFriendsMetrics({})
|
||||||
|
}
|
||||||
setSessions(baseSessions)
|
setSessions(baseSessions)
|
||||||
sessionsHydratedAtRef.current = Date.now()
|
sessionsHydratedAtRef.current = Date.now()
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -3622,7 +3690,7 @@ function ExportPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
if (!isStale()) setIsLoading(false)
|
if (!isStale()) setIsLoading(false)
|
||||||
}
|
}
|
||||||
}, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, mergeSessionContentMetrics, patchSessionLoadTraceStage, resetSessionMediaMetricLoader, resetSessionMutualFriendsLoader, syncContactTypeCounts])
|
}, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, mergeSessionContentMetrics, patchSessionLoadTraceStage, rebuildSessionMutualFriendsStateFromDirectMetrics, resetSessionMediaMetricLoader, resetSessionMutualFriendsLoader, syncContactTypeCounts])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isExportRoute) return
|
if (!isExportRoute) return
|
||||||
@@ -3630,10 +3698,7 @@ function ExportPage() {
|
|||||||
const hasFreshSessionSnapshot = hasBaseConfigReadyRef.current &&
|
const hasFreshSessionSnapshot = hasBaseConfigReadyRef.current &&
|
||||||
sessionsRef.current.length > 0 &&
|
sessionsRef.current.length > 0 &&
|
||||||
now - sessionsHydratedAtRef.current <= EXPORT_REENTER_SESSION_SOFT_REFRESH_MS
|
now - sessionsHydratedAtRef.current <= EXPORT_REENTER_SESSION_SOFT_REFRESH_MS
|
||||||
const hasFreshSnsSnapshot = hasSeededSnsStatsRef.current &&
|
const baseConfigPromise = loadBaseConfig()
|
||||||
now - snsStatsHydratedAtRef.current <= EXPORT_REENTER_SNS_SOFT_REFRESH_MS
|
|
||||||
|
|
||||||
void loadBaseConfig()
|
|
||||||
void ensureSharedTabCountsLoaded()
|
void ensureSharedTabCountsLoaded()
|
||||||
if (!hasFreshSessionSnapshot) {
|
if (!hasFreshSessionSnapshot) {
|
||||||
void loadSessions()
|
void loadSessions()
|
||||||
@@ -3641,9 +3706,14 @@ function ExportPage() {
|
|||||||
|
|
||||||
// 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。
|
// 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
if (!hasFreshSnsSnapshot) {
|
void (async () => {
|
||||||
void loadSnsStats({ full: true })
|
await baseConfigPromise
|
||||||
}
|
const hasFreshSnsSnapshot = hasSeededSnsStatsRef.current &&
|
||||||
|
Date.now() - snsStatsHydratedAtRef.current <= EXPORT_REENTER_SNS_SOFT_REFRESH_MS
|
||||||
|
if (!hasFreshSnsSnapshot) {
|
||||||
|
void loadSnsStats({ full: true })
|
||||||
|
}
|
||||||
|
})()
|
||||||
}, 120)
|
}, 120)
|
||||||
|
|
||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
@@ -4988,9 +5058,14 @@ function ExportPage() {
|
|||||||
window.clearTimeout(sessionMutualFriendsBackgroundFeedTimerRef.current)
|
window.clearTimeout(sessionMutualFriendsBackgroundFeedTimerRef.current)
|
||||||
sessionMutualFriendsBackgroundFeedTimerRef.current = null
|
sessionMutualFriendsBackgroundFeedTimerRef.current = null
|
||||||
}
|
}
|
||||||
|
if (sessionMutualFriendsPersistTimerRef.current) {
|
||||||
|
window.clearTimeout(sessionMutualFriendsPersistTimerRef.current)
|
||||||
|
sessionMutualFriendsPersistTimerRef.current = null
|
||||||
|
}
|
||||||
void flushSessionMediaMetricCache()
|
void flushSessionMediaMetricCache()
|
||||||
|
void flushSessionMutualFriendsCache()
|
||||||
}
|
}
|
||||||
}, [flushSessionMediaMetricCache])
|
}, [flushSessionMediaMetricCache, flushSessionMutualFriendsCache])
|
||||||
|
|
||||||
const contactByUsername = useMemo(() => {
|
const contactByUsername = useMemo(() => {
|
||||||
const map = new Map<string, ContactInfo>()
|
const map = new Map<string, ContactInfo>()
|
||||||
@@ -5254,6 +5329,23 @@ function ExportPage() {
|
|||||||
console.error('导出页读取会话统计缓存失败:', error)
|
console.error('导出页读取会话统计缓存失败:', error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const relationCacheResult = await window.electronAPI.chat.getExportSessionStats(
|
||||||
|
[normalizedSessionId],
|
||||||
|
{ includeRelations: true, allowStaleCache: true, cacheOnly: true }
|
||||||
|
)
|
||||||
|
if (requestSeq !== detailRequestSeqRef.current) return
|
||||||
|
if (relationCacheResult.success && relationCacheResult.data) {
|
||||||
|
const relationMetric = relationCacheResult.data[normalizedSessionId] as SessionExportMetric | undefined
|
||||||
|
const relationCacheMeta = relationCacheResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
|
||||||
|
if (relationMetric) {
|
||||||
|
applySessionDetailStats(normalizedSessionId, relationMetric, relationCacheMeta, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导出页读取会话关系缓存失败:', error)
|
||||||
|
}
|
||||||
|
|
||||||
const lastPreciseAt = sessionPreciseRefreshAtRef.current[preciseCacheKey] || 0
|
const lastPreciseAt = sessionPreciseRefreshAtRef.current[preciseCacheKey] || 0
|
||||||
const hasRecentPrecise = Date.now() - lastPreciseAt <= DETAIL_PRECISE_REFRESH_COOLDOWN_MS
|
const hasRecentPrecise = Date.now() - lastPreciseAt <= DETAIL_PRECISE_REFRESH_COOLDOWN_MS
|
||||||
const shouldRunPreciseRefresh = !hasRecentPrecise && (!quickMetric || Boolean(quickCacheMeta?.stale))
|
const shouldRunPreciseRefresh = !hasRecentPrecise && (!quickMetric || Boolean(quickCacheMeta?.stale))
|
||||||
@@ -5302,16 +5394,36 @@ function ExportPage() {
|
|||||||
}
|
}
|
||||||
}, [applySessionDetailStats, contactByUsername, mergeSessionContentMetrics, sessionContentMetrics, sessionMessageCounts, sessionRowByUsername])
|
}, [applySessionDetailStats, contactByUsername, mergeSessionContentMetrics, sessionContentMetrics, sessionMessageCounts, sessionRowByUsername])
|
||||||
|
|
||||||
const loadSessionRelationStats = useCallback(async () => {
|
const loadSessionRelationStats = useCallback(async (options?: { forceRefresh?: boolean }) => {
|
||||||
const normalizedSessionId = String(sessionDetail?.wxid || '').trim()
|
const normalizedSessionId = String(sessionDetail?.wxid || '').trim()
|
||||||
if (!normalizedSessionId || isLoadingSessionRelationStats) return
|
if (!normalizedSessionId || isLoadingSessionRelationStats) return
|
||||||
|
|
||||||
const requestSeq = detailRequestSeqRef.current
|
const requestSeq = detailRequestSeqRef.current
|
||||||
|
const forceRefresh = options?.forceRefresh === true
|
||||||
setIsLoadingSessionRelationStats(true)
|
setIsLoadingSessionRelationStats(true)
|
||||||
try {
|
try {
|
||||||
|
if (!forceRefresh) {
|
||||||
|
const relationCacheResult = await window.electronAPI.chat.getExportSessionStats(
|
||||||
|
[normalizedSessionId],
|
||||||
|
{ includeRelations: true, allowStaleCache: true, cacheOnly: true }
|
||||||
|
)
|
||||||
|
if (requestSeq !== detailRequestSeqRef.current) return
|
||||||
|
|
||||||
|
const relationMetric = relationCacheResult.success && relationCacheResult.data
|
||||||
|
? relationCacheResult.data[normalizedSessionId] as SessionExportMetric | undefined
|
||||||
|
: undefined
|
||||||
|
const relationCacheMeta = relationCacheResult.success
|
||||||
|
? relationCacheResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
|
||||||
|
: undefined
|
||||||
|
if (relationMetric) {
|
||||||
|
applySessionDetailStats(normalizedSessionId, relationMetric, relationCacheMeta, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const relationResult = await window.electronAPI.chat.getExportSessionStats(
|
const relationResult = await window.electronAPI.chat.getExportSessionStats(
|
||||||
[normalizedSessionId],
|
[normalizedSessionId],
|
||||||
{ includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true }
|
{ includeRelations: true, forceRefresh, preferAccurateSpecialTypes: true }
|
||||||
)
|
)
|
||||||
if (requestSeq !== detailRequestSeqRef.current) return
|
if (requestSeq !== detailRequestSeqRef.current) return
|
||||||
|
|
||||||
@@ -5333,6 +5445,60 @@ function ExportPage() {
|
|||||||
}
|
}
|
||||||
}, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid])
|
}, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid])
|
||||||
|
|
||||||
|
const handleRefreshTableData = useCallback(async () => {
|
||||||
|
const scopeKey = await ensureExportCacheScope()
|
||||||
|
|
||||||
|
resetSessionMutualFriendsLoader()
|
||||||
|
sessionMutualFriendsMetricsRef.current = {}
|
||||||
|
setSessionMutualFriendsMetrics({})
|
||||||
|
closeSessionMutualFriendsDialog()
|
||||||
|
try {
|
||||||
|
await configService.clearExportSessionMutualFriendsCache(scopeKey)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('清理导出页共同好友缓存失败:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSessionCountStageReady) {
|
||||||
|
const visibleTargetIds = collectVisibleSessionMutualFriendsTargets(filteredContacts)
|
||||||
|
const visibleTargetSet = new Set(visibleTargetIds)
|
||||||
|
const remainingTargetIds = sessionsRef.current
|
||||||
|
.filter((session) => session.hasSession && isSingleContactSession(session.username) && !visibleTargetSet.has(session.username))
|
||||||
|
.map((session) => session.username)
|
||||||
|
|
||||||
|
if (visibleTargetIds.length > 0) {
|
||||||
|
enqueueSessionMutualFriendsRequests(visibleTargetIds, { front: true })
|
||||||
|
}
|
||||||
|
if (remainingTargetIds.length > 0) {
|
||||||
|
enqueueSessionMutualFriendsRequests(remainingTargetIds)
|
||||||
|
}
|
||||||
|
scheduleSessionMutualFriendsWorker()
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
loadContactsList({ scopeKey }),
|
||||||
|
loadSnsStats({ full: true }),
|
||||||
|
loadSnsUserPostCounts({ force: true })
|
||||||
|
])
|
||||||
|
|
||||||
|
if (String(sessionDetail?.wxid || '').trim()) {
|
||||||
|
void loadSessionRelationStats({ forceRefresh: true })
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
closeSessionMutualFriendsDialog,
|
||||||
|
collectVisibleSessionMutualFriendsTargets,
|
||||||
|
enqueueSessionMutualFriendsRequests,
|
||||||
|
ensureExportCacheScope,
|
||||||
|
filteredContacts,
|
||||||
|
isSessionCountStageReady,
|
||||||
|
loadContactsList,
|
||||||
|
loadSessionRelationStats,
|
||||||
|
loadSnsStats,
|
||||||
|
loadSnsUserPostCounts,
|
||||||
|
resetSessionMutualFriendsLoader,
|
||||||
|
scheduleSessionMutualFriendsWorker,
|
||||||
|
sessionDetail?.wxid
|
||||||
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showSessionDetailPanel || !sessionDetailSupportsSnsTimeline) return
|
if (!showSessionDetailPanel || !sessionDetailSupportsSnsTimeline) return
|
||||||
if (snsUserPostCountsStatus === 'idle') {
|
if (snsUserPostCountsStatus === 'idle') {
|
||||||
@@ -6371,7 +6537,7 @@ function ExportPage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button className="secondary-btn" onClick={() => void loadContactsList()} disabled={isContactsListLoading}>
|
<button className="secondary-btn" onClick={() => void handleRefreshTableData()} disabled={isContactsListLoading}>
|
||||||
<RefreshCw size={14} className={isContactsListLoading ? 'spin' : ''} />
|
<RefreshCw size={14} className={isContactsListLoading ? 'spin' : ''} />
|
||||||
刷新
|
刷新
|
||||||
</button>
|
</button>
|
||||||
@@ -6468,7 +6634,7 @@ function ExportPage() {
|
|||||||
<li>可能原因3:数据库连接状态异常或 IPC 调用卡住。</li>
|
<li>可能原因3:数据库连接状态异常或 IPC 调用卡住。</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div className="issue-actions">
|
<div className="issue-actions">
|
||||||
<button className="issue-btn primary" onClick={() => void loadContactsList()}>
|
<button className="issue-btn primary" onClick={() => void handleRefreshTableData()}>
|
||||||
<RefreshCw size={14} />
|
<RefreshCw size={14} />
|
||||||
<span>重试加载</span>
|
<span>重试加载</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const CONFIG_KEYS = {
|
|||||||
EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP: 'exportSessionContentMetricCacheMap',
|
EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP: 'exportSessionContentMetricCacheMap',
|
||||||
EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap',
|
EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap',
|
||||||
EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP: 'exportSnsUserPostCountsCacheMap',
|
EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP: 'exportSnsUserPostCountsCacheMap',
|
||||||
|
EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP: 'exportSessionMutualFriendsCacheMap',
|
||||||
SNS_PAGE_CACHE_MAP: 'snsPageCacheMap',
|
SNS_PAGE_CACHE_MAP: 'snsPageCacheMap',
|
||||||
CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs',
|
CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs',
|
||||||
CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap',
|
CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap',
|
||||||
@@ -596,6 +597,34 @@ export interface ExportSnsUserPostCountsCacheItem {
|
|||||||
counts: Record<string, number>
|
counts: Record<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ExportSessionMutualFriendDirection = 'incoming' | 'outgoing' | 'bidirectional'
|
||||||
|
export type ExportSessionMutualFriendBehavior = 'likes' | 'comments' | 'both'
|
||||||
|
|
||||||
|
export interface ExportSessionMutualFriendCacheItem {
|
||||||
|
name: string
|
||||||
|
incomingLikeCount: number
|
||||||
|
incomingCommentCount: number
|
||||||
|
outgoingLikeCount: number
|
||||||
|
outgoingCommentCount: number
|
||||||
|
totalCount: number
|
||||||
|
latestTime: number
|
||||||
|
direction: ExportSessionMutualFriendDirection
|
||||||
|
behavior: ExportSessionMutualFriendBehavior
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportSessionMutualFriendsCacheEntry {
|
||||||
|
count: number
|
||||||
|
items: ExportSessionMutualFriendCacheItem[]
|
||||||
|
loadedPosts: number
|
||||||
|
totalPosts: number | null
|
||||||
|
computedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportSessionMutualFriendsCacheItem {
|
||||||
|
updatedAt: number
|
||||||
|
metrics: Record<string, ExportSessionMutualFriendsCacheEntry>
|
||||||
|
}
|
||||||
|
|
||||||
export interface SnsPageOverviewCache {
|
export interface SnsPageOverviewCache {
|
||||||
totalPosts: number
|
totalPosts: number
|
||||||
totalFriends: number
|
totalFriends: number
|
||||||
@@ -855,6 +884,148 @@ export async function setExportSnsUserPostCountsCache(
|
|||||||
await config.set(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP, map)
|
await config.set(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP, map)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeMutualFriendDirection = (value: unknown): ExportSessionMutualFriendDirection | null => {
|
||||||
|
if (value === 'incoming' || value === 'outgoing' || value === 'bidirectional') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeMutualFriendBehavior = (value: unknown): ExportSessionMutualFriendBehavior | null => {
|
||||||
|
if (value === 'likes' || value === 'comments' || value === 'both') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeExportSessionMutualFriendsCacheEntry = (raw: unknown): ExportSessionMutualFriendsCacheEntry | null => {
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
const source = raw as Record<string, unknown>
|
||||||
|
const count = Number(source.count)
|
||||||
|
const loadedPosts = Number(source.loadedPosts)
|
||||||
|
const computedAt = Number(source.computedAt)
|
||||||
|
const itemsRaw = Array.isArray(source.items) ? source.items : []
|
||||||
|
const totalPostsRaw = source.totalPosts
|
||||||
|
const totalPosts = totalPostsRaw === null || totalPostsRaw === undefined
|
||||||
|
? null
|
||||||
|
: Number(totalPostsRaw)
|
||||||
|
|
||||||
|
if (!Number.isFinite(count) || count < 0 || !Number.isFinite(loadedPosts) || loadedPosts < 0 || !Number.isFinite(computedAt) || computedAt < 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: ExportSessionMutualFriendCacheItem[] = []
|
||||||
|
for (const itemRaw of itemsRaw) {
|
||||||
|
if (!itemRaw || typeof itemRaw !== 'object') continue
|
||||||
|
const item = itemRaw as Record<string, unknown>
|
||||||
|
const name = String(item.name || '').trim()
|
||||||
|
const direction = normalizeMutualFriendDirection(item.direction)
|
||||||
|
const behavior = normalizeMutualFriendBehavior(item.behavior)
|
||||||
|
const incomingLikeCount = Number(item.incomingLikeCount)
|
||||||
|
const incomingCommentCount = Number(item.incomingCommentCount)
|
||||||
|
const outgoingLikeCount = Number(item.outgoingLikeCount)
|
||||||
|
const outgoingCommentCount = Number(item.outgoingCommentCount)
|
||||||
|
const totalCount = Number(item.totalCount)
|
||||||
|
const latestTime = Number(item.latestTime)
|
||||||
|
if (!name || !direction || !behavior) continue
|
||||||
|
if (
|
||||||
|
!Number.isFinite(incomingLikeCount) || incomingLikeCount < 0 ||
|
||||||
|
!Number.isFinite(incomingCommentCount) || incomingCommentCount < 0 ||
|
||||||
|
!Number.isFinite(outgoingLikeCount) || outgoingLikeCount < 0 ||
|
||||||
|
!Number.isFinite(outgoingCommentCount) || outgoingCommentCount < 0 ||
|
||||||
|
!Number.isFinite(totalCount) || totalCount < 0 ||
|
||||||
|
!Number.isFinite(latestTime) || latestTime < 0
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items.push({
|
||||||
|
name,
|
||||||
|
incomingLikeCount: Math.floor(incomingLikeCount),
|
||||||
|
incomingCommentCount: Math.floor(incomingCommentCount),
|
||||||
|
outgoingLikeCount: Math.floor(outgoingLikeCount),
|
||||||
|
outgoingCommentCount: Math.floor(outgoingCommentCount),
|
||||||
|
totalCount: Math.floor(totalCount),
|
||||||
|
latestTime: Math.floor(latestTime),
|
||||||
|
direction,
|
||||||
|
behavior
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
count: Math.floor(count),
|
||||||
|
items,
|
||||||
|
loadedPosts: Math.floor(loadedPosts),
|
||||||
|
totalPosts: totalPosts === null
|
||||||
|
? null
|
||||||
|
: (Number.isFinite(totalPosts) && totalPosts >= 0 ? Math.floor(totalPosts) : null),
|
||||||
|
computedAt: Math.floor(computedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExportSessionMutualFriendsCache(scopeKey: string): Promise<ExportSessionMutualFriendsCacheItem | null> {
|
||||||
|
if (!scopeKey) return null
|
||||||
|
const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP)
|
||||||
|
if (!value || typeof value !== 'object') return null
|
||||||
|
const rawMap = value as Record<string, unknown>
|
||||||
|
const rawItem = rawMap[scopeKey]
|
||||||
|
if (!rawItem || typeof rawItem !== 'object') return null
|
||||||
|
|
||||||
|
const rawUpdatedAt = (rawItem as Record<string, unknown>).updatedAt
|
||||||
|
const rawMetrics = (rawItem as Record<string, unknown>).metrics
|
||||||
|
if (!rawMetrics || typeof rawMetrics !== 'object') return null
|
||||||
|
|
||||||
|
const metrics: Record<string, ExportSessionMutualFriendsCacheEntry> = {}
|
||||||
|
for (const [sessionIdRaw, metricRaw] of Object.entries(rawMetrics as Record<string, unknown>)) {
|
||||||
|
const sessionId = String(sessionIdRaw || '').trim()
|
||||||
|
if (!sessionId) continue
|
||||||
|
const metric = normalizeExportSessionMutualFriendsCacheEntry(metricRaw)
|
||||||
|
if (!metric) continue
|
||||||
|
metrics[sessionId] = metric
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0,
|
||||||
|
metrics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setExportSessionMutualFriendsCache(
|
||||||
|
scopeKey: string,
|
||||||
|
metrics: Record<string, ExportSessionMutualFriendsCacheEntry>
|
||||||
|
): Promise<void> {
|
||||||
|
if (!scopeKey) return
|
||||||
|
const current = await config.get(CONFIG_KEYS.EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP)
|
||||||
|
const map = current && typeof current === 'object'
|
||||||
|
? { ...(current as Record<string, unknown>) }
|
||||||
|
: {}
|
||||||
|
|
||||||
|
const normalized: Record<string, ExportSessionMutualFriendsCacheEntry> = {}
|
||||||
|
for (const [sessionIdRaw, metricRaw] of Object.entries(metrics || {})) {
|
||||||
|
const sessionId = String(sessionIdRaw || '').trim()
|
||||||
|
if (!sessionId) continue
|
||||||
|
const metric = normalizeExportSessionMutualFriendsCacheEntry(metricRaw)
|
||||||
|
if (!metric) continue
|
||||||
|
normalized[sessionId] = metric
|
||||||
|
}
|
||||||
|
|
||||||
|
map[scopeKey] = {
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
metrics: normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
await config.set(CONFIG_KEYS.EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP, map)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearExportSessionMutualFriendsCache(scopeKey: string): Promise<void> {
|
||||||
|
if (!scopeKey) return
|
||||||
|
const current = await config.get(CONFIG_KEYS.EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP)
|
||||||
|
if (!current || typeof current !== 'object') return
|
||||||
|
const map = { ...(current as Record<string, unknown>) }
|
||||||
|
if (!(scopeKey in map)) return
|
||||||
|
delete map[scopeKey]
|
||||||
|
await config.set(CONFIG_KEYS.EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP, map)
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSnsPageCache(scopeKey: string): Promise<SnsPageCacheItem | null> {
|
export async function getSnsPageCache(scopeKey: string): Promise<SnsPageCacheItem | null> {
|
||||||
if (!scopeKey) return null
|
if (!scopeKey) return null
|
||||||
const value = await config.get(CONFIG_KEYS.SNS_PAGE_CACHE_MAP)
|
const value = await config.get(CONFIG_KEYS.SNS_PAGE_CACHE_MAP)
|
||||||
|
|||||||
Reference in New Issue
Block a user