mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 19:47:08 +08:00
feat: 群日报 9 宫格头像自动反推 + 持续时长语义 + 自动登录
- group-report-service: enrichAvatarsFromGroup 从群成员快照反推真头像 - group-report-service: 修 SVG data URL 正则,fallback 现在能正常嵌入 - group-report (shared): GroupReportMetadata/Result 加 talker/warnings 字段 - timeSpan 改为持续时长(\"1 h\" / \"30 min\" / \"2 d\" 紧凑半角) - App.tsx 启动自动连接(env var + safeStorage) - Wcdb4Client 父目录自动解析为最新 wxid - HTTP server EADDRINUSE 友好提示 + 指数退避 - installSafeConsole 修 EPIPE crash - SettingsPanel 测试连接后自动更新 dbRoot
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
out
|
out
|
||||||
docs/
|
|
||||||
.env
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
---
|
||||||
|
name: wechatexplorer-reader
|
||||||
|
description: 通过本地 HTTP API 读取 WechatExplorer 解锁后的微信聊天数据(本地服务由 WechatExplorer.app 提供)。当用户提到微信聊天记录、群消息、看看群里说了什么、查一下微信、分析微信对话、总结群聊等场景时,使用此技能。注意:此技能的数据源是用户本机 WechatExplorer app,而非 chatlog/WeFlow。
|
||||||
|
---
|
||||||
|
|
||||||
|
# WechatExplorer Reader
|
||||||
|
|
||||||
|
通过本地 HTTP API(`http://127.0.0.1:6131`)读取 WechatExplorer 已经解锁的微信数据库内容。
|
||||||
|
|
||||||
|
## 数据源
|
||||||
|
|
||||||
|
- **本服务由 WechatExplorer.app 提供**,数据完全在本地处理,不会上传任何服务器
|
||||||
|
- 用户必须在 WechatExplorer 主窗口完成**首次密钥配置**(解锁 WCDB 数据库)
|
||||||
|
- 默认监听 `127.0.0.1:6131`,仅本机可访问,无需鉴权
|
||||||
|
|
||||||
|
## 前置条件
|
||||||
|
|
||||||
|
1. **安装并启动 WechatExplorer.app**(从项目 release 页面下载)
|
||||||
|
2. **首次启动时完成密钥配置**:在主界面第一步输入微信数据库密钥(64 位 hex),完成 WCDB 初始化
|
||||||
|
3. **如需 7×24 提供 API**:用 `WXE_TRAY=1` 或 `--tray` 参数启动 app,启用菜单栏常驻模式(主窗口关闭后服务仍在)
|
||||||
|
|
||||||
|
## API 列表
|
||||||
|
|
||||||
|
GET 用于读取数据,`POST /api/v1/report` 用于生成群日报(HTML + 长图)。所有端点返回 JSON。
|
||||||
|
|
||||||
|
| 端点 | 用途 | 关键参数 |
|
||||||
|
|------|------|---------|
|
||||||
|
| `GET /api/v1/health` | 健康检查 + 是否已初始化 | — |
|
||||||
|
| `GET /api/v1/current_time` | 获取当前本地时间(用于"今天/昨天"换算) | — |
|
||||||
|
| `GET /api/v1/contact` | 联系人 / 群聊列表 | `filter`(昵称模糊)、`type`(`user` \| `group`) |
|
||||||
|
| `GET /api/v1/chatroom` | 群聊列表(等同 contact?type=group) | `keyword` |
|
||||||
|
| `GET /api/v1/recent_chat` | 最近会话 | `limit`(默认 50) |
|
||||||
|
| `GET /api/v1/chatlog` | 聊天记录 | `talker`、`time` 或 `startTime`/`endTime` |
|
||||||
|
| `GET /api/v1/group_snapshot` | 群成员快照 | `md5` |
|
||||||
|
| `GET /api/v1/resolve` | 把昵称/wxid/md5 解析成 md5 | `q` |
|
||||||
|
| `POST /api/v1/report` | 生成群聊日报 HTML + 长图 PNG | JSON body(见下文,推荐传 `metadata.talker` 让服务端自动反推真头像) |
|
||||||
|
|
||||||
|
### `talker` 参数可接受的值
|
||||||
|
|
||||||
|
`chatlog` 和 `recent_chat` 的 `talker` / 列表项 ID 支持以下三种形式,服务端会按 `nickname → wxid → md5` 顺序匹配:
|
||||||
|
|
||||||
|
1. **群昵称 / 好友备注**(模糊匹配,如 `技术交流`、`摸鱼群`)
|
||||||
|
2. **微信 wxid**(如 `wxid_abc123`、`gh_xxxxx@chatroom`)
|
||||||
|
3. **会话 md5**(如 `49023470180@chatroom` 的 md5 哈希,可在 `contact` 接口里看到)
|
||||||
|
|
||||||
|
不确定时先调 `GET /api/v1/resolve?q=<输入>` 校验,返回 `{ md5, m_nsUsrName, m_nsNickName, type, ... }`。
|
||||||
|
|
||||||
|
### `chatroom` 与 `contact?type=group` 字段一致性
|
||||||
|
|
||||||
|
`/chatroom` 和 `/contact?type=group` 返回的是**同一个集合**(都是 `listContacts().filter(type==='group')`),字段也完全一致:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"m_nsUsrName": "49023470180@chatroom", // wxid, 用作 chatlog 的 talker
|
||||||
|
"m_nsNickName": { "buffer": "...", "type": "Buffer" }, // nickname 原 buffer
|
||||||
|
"type": "group",
|
||||||
|
"md5": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
需要 `displayName` 时从 `m_nsNickName` 里解析;需要拉消息就传 `m_nsUsrName` 当 talker。
|
||||||
|
|
||||||
|
## 时间范围格式(`time` 参数)
|
||||||
|
|
||||||
|
支持以下格式:
|
||||||
|
|
||||||
|
| 输入 | 含义 |
|
||||||
|
|------|------|
|
||||||
|
| `2026-07-03` | 单日 00:00:00 ~ 23:59:59 |
|
||||||
|
| `2026-07-01~2026-07-03` | 日期范围(闭区间) |
|
||||||
|
| `2026-07-03/14:30` | 单分钟(从 14:30:00 起 60 秒) |
|
||||||
|
| `2026-07-03/14:30~2026-07-03/15:30` | 精确到分钟的范围 |
|
||||||
|
|
||||||
|
也可以直接传 unix 秒级时间戳作为 `startTime` 和 `endTime`。
|
||||||
|
|
||||||
|
### "今天 / 昨天 / 本周" 的时区语义
|
||||||
|
|
||||||
|
所有 `time` / `startTime` / `endTime` 都按**用户本机时区**解析(由 `current_time` 里的 `timezone` 字段给出,典型为 `Asia/Shanghai`)。含义如下:
|
||||||
|
|
||||||
|
- "今天 2026-07-03" → 本机 2026-07-03 00:00:00 ~ 23:59:59(北京时间 24 小时),**不是** UTC 当天
|
||||||
|
- "昨天" → 本机昨天 0 点 ~ 23:59:59
|
||||||
|
- "本周" → 本周一 0 点 ~ 当前时刻(按本机时区所在周的周一)
|
||||||
|
|
||||||
|
跨时区时(如用户在国外):仍以本机时区为准,需要按 UTC 处理时显式传 unix 时间戳。
|
||||||
|
|
||||||
|
## 时间预检工作流(Time-Aware Workflow)
|
||||||
|
|
||||||
|
**重要**:只要用户请求中包含"今天"、"昨天"、"本周"、"刚才"等相对时间概念,**禁止**直接生成日期字符串。
|
||||||
|
|
||||||
|
**步骤 1**:先调用 `current_time` 工具获取本地 RFC3339 时间。
|
||||||
|
**步骤 2**:根据返回的时间计算对应的 `time` 参数。
|
||||||
|
**步骤 3**:用计算后的参数调 `chatlog`。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
- 用户: "今天 摸鱼交流群 聊了啥?"
|
||||||
|
- AI: 先 `GET /api/v1/current_time` → 得到 `2026-07-03T14:30:00+08:00` → 计算 `time=2026-07-03` → `GET /api/v1/chatlog?talker=摸鱼交流群&time=2026-07-03`
|
||||||
|
|
||||||
|
## 多步上下文检索(强制)
|
||||||
|
|
||||||
|
当查询特定话题或特定发送者发言时,**必须**按以下流程操作:
|
||||||
|
|
||||||
|
1. **初步定位**:用 `contact` 或 `chatroom` 端点确定群聊 md5 / wxid
|
||||||
|
2. **粗查**:用 `chatlog` + 较宽时间范围找到相关消息时间点
|
||||||
|
3. **精查**:对每个关键时间点分别查前后 15-30 分钟(不带任何 keyword 过滤),用完整上下文分析
|
||||||
|
|
||||||
|
**禁止**:仅凭一次粗查结果直接回答用户。
|
||||||
|
|
||||||
|
## 生成群日报(POST /api/v1/report)
|
||||||
|
|
||||||
|
当用户希望输出**可视化群日报**(长图 PNG + HTML 邮件版)时,用这个端点。WechatExplorer 内置 `mobile_daily_report.html` 模板,渲染后会同时落盘 `htmlPath` 和 `pngPath`,并返回 `imageDataUrl` 可直接预览。
|
||||||
|
|
||||||
|
### 请求体(`GroupReportExportRequest`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"report": {
|
||||||
|
"overview": "一句话总览,20-80 字",
|
||||||
|
"topics": [
|
||||||
|
{
|
||||||
|
"title": "话题标题",
|
||||||
|
"timeRange": "10:00-12:30",
|
||||||
|
"heat": "高", // "高" | "中" | "低"
|
||||||
|
"participants": ["张三", "李四"],
|
||||||
|
"summary": "本话题讨论了什么",
|
||||||
|
"conclusion": "可选,达成的结论",
|
||||||
|
"keywords": ["关键词1", "关键词2"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"resources": [
|
||||||
|
{ "title": "链接/文件标题", "description": "为什么重要", "sender": "张三" }
|
||||||
|
],
|
||||||
|
"importantMessages": [
|
||||||
|
{ "sender": "张三", "time": "10:23", "content": "原消息文本", "note": "为什么重要" }
|
||||||
|
],
|
||||||
|
"quotes": [
|
||||||
|
{
|
||||||
|
"messages": [{ "sender": "李四", "content": "原话1" }, { "sender": "王五", "content": "原话2" }],
|
||||||
|
"note": "为什么这些话值得引用"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"qa": [
|
||||||
|
{ "question": "Q", "answer": "A", "answerer": "解答人(可选)" }
|
||||||
|
],
|
||||||
|
"analytics": {
|
||||||
|
"topicHeat": [{ "topic": "话题1", "score": 9.5 }],
|
||||||
|
"activeTimeline": "10:00-12:00 为最活跃时段",
|
||||||
|
"topSpeakers": [{ "name": "张三", "count": 58 }]
|
||||||
|
},
|
||||||
|
"keywords": ["高频词1", "高频词2"]
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"groupName": "技术交流",
|
||||||
|
"reportDate": "2026-07-03",
|
||||||
|
"dateRange": "2026-07-03 全天",
|
||||||
|
"messageCount": 1234,
|
||||||
|
"activeUsers": 56,
|
||||||
|
"timeSpan": "00:00-23:59",
|
||||||
|
"generatedAt": "2026-07-03 22:00",
|
||||||
|
"recordNote": "本日报由 WechatExplorer 自动生成",
|
||||||
|
"footerNote": "底部附加说明",
|
||||||
|
"heroParticipants": ["张三", "李四"],
|
||||||
|
"avatars": {},
|
||||||
|
"talker": "技术交流",
|
||||||
|
"timeRange": "2026-07-03"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 响应(`GroupReportExportResult`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"htmlPath": "/Users/.../Desktop/技术交流_日报_2026-07-03.html",
|
||||||
|
"pngPath": "/Users/.../Desktop/技术交流_日报_2026-07-03.png",
|
||||||
|
"imageDataUrl": "data:image/png;base64,iVBORw0K..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功返回 200;失败返回 500 + `{ success: false, error: "..." }`。HTML 和 PNG 用 `mobile_daily_report.html` 模板渲染,长图宽度自适应移动端预览。
|
||||||
|
|
||||||
|
### 典型工作流
|
||||||
|
|
||||||
|
1. 调 `current_time` + `chatlog` 拉取当天/目标时间段消息
|
||||||
|
2. LLM 总结生成 `report` + `metadata`(直接走 AI 总结即可,无需自己造数据)
|
||||||
|
3. POST 到 `/api/v1/report` 拿到 `htmlPath` / `pngPath`,把文件路径告诉用户即可在 Finder 打开
|
||||||
|
4. **不要**自己拼 HTML/PNG,模板已内置,只需组织好 report/metadata 字段
|
||||||
|
|
||||||
|
### 必填字段与隐式约束(踩坑提示)
|
||||||
|
|
||||||
|
`metadata` 的以下字段**必填**,缺一返回 500:
|
||||||
|
|
||||||
|
- `groupName`、`reportDate`、`dateRange`、`generatedAt`
|
||||||
|
- `heroParticipants`:数组,模板会把每个名字当 key 去 `metadata.avatars[name]` 取头像图
|
||||||
|
- `avatars`:对象,**每个 `heroParticipants` 里的名字都必须有这个 key**(没有就传 `""`,**不要省略整段**),否则模板渲染会抛 `Cannot read properties of undefined (reading '<名字>')` 报 500
|
||||||
|
|
||||||
|
`report` 的以下字段**必须存在**(空就传 `[]`,**不能省略**),否则模板遍历时会抛 `Cannot read properties of undefined (reading 'map')` 报 500:
|
||||||
|
|
||||||
|
- `report.topics`(至少 1 个,完全没话题就改用纯文本总结,不要硬生成空日报)
|
||||||
|
- `report.resources`
|
||||||
|
- `report.importantMessages`
|
||||||
|
- `report.quotes`
|
||||||
|
- `report.qa`
|
||||||
|
- `report.analytics.topicHeat`
|
||||||
|
- `report.analytics.topSpeakers`(至少 1 个)
|
||||||
|
- `report.keywords`
|
||||||
|
|
||||||
|
最小安全示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"report": {
|
||||||
|
"overview": "...",
|
||||||
|
"topics": [],
|
||||||
|
"resources": [],
|
||||||
|
"importantMessages": [],
|
||||||
|
"quotes": [],
|
||||||
|
"qa": [],
|
||||||
|
"analytics": { "topicHeat": [], "activeTimeline": "", "topSpeakers": [] },
|
||||||
|
"keywords": []
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"groupName": "技术交流",
|
||||||
|
"reportDate": "2026-07-07",
|
||||||
|
"dateRange": "2026-07-07 全天",
|
||||||
|
"heroParticipants": ["张三", "李四"],
|
||||||
|
"avatars": { "张三": "", "李四": "" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`report.importantMessages[].time` 用 `HH:mm` 格式(不要 ISO 时间戳);`report.analytics.topicHeat[].score` 数字 0-10。
|
||||||
|
|
||||||
|
### 4 个数字格子的内容必须紧凑(避免塌陷)
|
||||||
|
|
||||||
|
模板顶部的 4 个统计格(`消息数 / 活跃人数 / 时间跨度 / 主要话题`)宽度均分,内容过长会被截断或换行:
|
||||||
|
|
||||||
|
| 字段 | 推荐格式 | 反例(会撑爆格子) |
|
||||||
|
|------|---------|----------------|
|
||||||
|
| `metadata.messageCount` | 纯数字 `"1234"` | `"约 1.2k 条"` |
|
||||||
|
| `metadata.activeUsers` | 纯数字 `"56"` | `"大约 50 多人"` |
|
||||||
|
| `metadata.timeSpan` | **持续时长紧凑半角** `"1 h"` / `"30 min"` / `"2 d"` | `"1 小时"` / `"7 小时"` / `"1天3小时"` |
|
||||||
|
| `metadata.topicCount` 等 | 数字 / 短中文 | 长句子 |
|
||||||
|
|
||||||
|
`timeSpan` 是**首条到末条消息的持续时长**,不是时间区间。**单位用半角空格分隔**:
|
||||||
|
|
||||||
|
- `< 1 h` → `"30 min"`
|
||||||
|
- `1~24 h` → `"1 h"` / `"7 h"`(整数,向上取整)
|
||||||
|
- `> 24 h` → `"2 d"`(整数,向上取整)
|
||||||
|
|
||||||
|
**首末条消息的具体时间点**:`dateRange` 字段会显示完整日期 + 起止时间(无长度限制),模板里 dateRange 是 hero 区的副标题,跟 stat 格子分开。
|
||||||
|
|
||||||
|
**区间叙事**(如"主要集中在上午 10 点-12 点")放 `report.analytics.activeTimeline`,那是模板里单独一段的描述,不被 stat 格子限制。
|
||||||
|
|
||||||
|
**不传 timeSpan**:服务端会用空字符串渲染(stat 格会空),subagent 应当总是算好时长填进来,或者 renderer 端会自动算(见 renderer 源码)。
|
||||||
|
|
||||||
|
### 头像:服务端自动反推(推荐)
|
||||||
|
|
||||||
|
**v1.4 起无需手动拼 `avatars` 字典**。在 `metadata` 里加 `talker`(群昵称/wxid/md5 都行),服务端会用 `getGroupSnapshot` 拉全量群成员,按 `nickname → avatar` 自动反推填进 `metadata.avatars`。LLM 总结里出现的 `heroParticipants` / `topics[].participants` / `topSpeakers[].name` 等所有名字都会被覆盖。
|
||||||
|
|
||||||
|
**优先级**:客户端传的 `avatars[name]`(非空字符串) > 服务端反推 > 占位 SVG(姓名首字母 + 随机色块)。
|
||||||
|
|
||||||
|
**回退**:不传 `talker` 时按 `metadata.avatars` 字典取;还取不到则生成 SVG 占位(`fallbackAvatar`),**不会变空白方块**(v1.4 修了 data URL 正则,SVG 占位能正常嵌入)。
|
||||||
|
|
||||||
|
**手动覆盖**:仍可传 `avatars` 字典强制使用自定义头像,例如 `{"张三": "data:image/jpeg;base64,..."}`。
|
||||||
|
|
||||||
|
**P2 风险**:群里有两人同名(如"杨伟")时,服务端只取首条;客户端可手动覆盖。
|
||||||
|
|
||||||
|
## 隐私安全原则
|
||||||
|
|
||||||
|
1. **最小化原则**:只返回用户明确请求的内容,不过度展开无关聊天
|
||||||
|
2. **本地处理**:所有数据来自用户本机,API 不缓存、不转发
|
||||||
|
3. **摘要优先**:对于大量聊天记录,先提供摘要而非完整 dump
|
||||||
|
4. **用户确认**:涉及敏感内容时,先展示摘要,让用户决定是否继续深入
|
||||||
|
|
||||||
|
## 典型工作流示例
|
||||||
|
|
||||||
|
**示例 1:今日群聊总结(纯文本)**
|
||||||
|
1. `GET /api/v1/current_time` → 获取今天日期
|
||||||
|
2. `GET /api/v1/chatroom?keyword=技术交流` → 找到目标群 md5
|
||||||
|
3. `GET /api/v1/chatlog?talker=技术交流&time=2026-07-03` → 拉取今天的聊天
|
||||||
|
4. AI 用 LLM 生成总结报告(话题 TOP N、最活跃发言者等)
|
||||||
|
|
||||||
|
**示例 2:搜索特定消息上下文**
|
||||||
|
1. `GET /api/v1/chatlog?talker=摸鱼群&time=2026-07-01~2026-07-03` → 粗查近 3 天
|
||||||
|
2. 在返回的消息中定位关键词出现的时间点 T1, T2, ...
|
||||||
|
3. 对每个 Ti 分别查 `chatlog?talker=摸鱼群&time=Ti-15min~Ti+15min`,分析上下文
|
||||||
|
|
||||||
|
**示例 3:群日报(可视化长图)**
|
||||||
|
1. `GET /api/v1/chatlog?talker=技术交流&time=2026-07-03` → 拉今天聊天
|
||||||
|
2. LLM 按上方 `GroupDailyReport` schema 总结出 `report` + `metadata`
|
||||||
|
3. `POST /api/v1/report` body = 上述 JSON → 拿到 `htmlPath` / `pngPath` / `imageDataUrl`
|
||||||
|
4. 把 `imageDataUrl` 给用户预览,把 `pngPath` 路径告诉用户用 Finder 打开
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
- `503` → WechatExplorer 未初始化(密钥未配置),提示用户在主窗口完成配置
|
||||||
|
- `404 talker not found` → talker 不存在,先调 `contact` 或 `resolve` 确认 md5/wxid
|
||||||
|
- `400 missing required parameter` → 检查必填参数(talker / md5 / q)
|
||||||
|
- `200` 但 `result.warnings: ['enrich skipped: talker "X" not found']` → `/report` 的 `metadata.talker` 解析失败,头像走 SVG fallback(不阻断生成)
|
||||||
|
- `200` 但 `result.warnings: ['enriched N member avatars from snapshot (M members)']` → enrich 成功(诊断用)
|
||||||
|
- `400 请求体为空 / 需包含 report 和 metadata` → 调用 `/report` 时 body 必须是非空 JSON,且有这两个顶层字段
|
||||||
|
- `500 success=false` → 模板渲染失败,通常因 `report` 字段缺失或 `metadata.groupName/reportDate` 为空,检查后重试
|
||||||
|
|
||||||
|
## 配置 Claude Desktop
|
||||||
|
|
||||||
|
把以下加入 `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"wechatexplorer": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@wechatexplorer/mcp-bridge"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(待 P3 实现 — MCP bridge 包,在此之前可直接用 `curl` 调用 HTTP API,或通过 mcp-remote 桥接。)
|
||||||
|
|
||||||
|
## 配置 Claude Code / Codex
|
||||||
|
|
||||||
|
在 `~/.claude/settings.json` 或项目级 `.claude/settings.local.json` 中:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"wechatexplorer": {
|
||||||
|
"url": "http://127.0.0.1:6131"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(视 MCP over HTTP 支持情况调整)
|
||||||
@@ -44,6 +44,11 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.hero-top > div:first-child {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
.hero h1 {
|
.hero h1 {
|
||||||
font-size: 23px;
|
font-size: 23px;
|
||||||
@@ -82,11 +87,15 @@
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 10px 6px;
|
padding: 10px 6px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.stat b {
|
.stat b {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
color: #07a352;
|
color: #07a352;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
.stat span {
|
.stat span {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -420,7 +429,7 @@
|
|||||||
<div class="stats">
|
<div class="stats">
|
||||||
<div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div>
|
<div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div>
|
||||||
<div class="stat"><b>{{ACTIVE_USERS}}</b><span>活跃人数</span></div>
|
<div class="stat"><b>{{ACTIVE_USERS}}</b><span>活跃人数</span></div>
|
||||||
<div class="stat"><b>{{TIME_SPAN}}</b><span>时间跨度</span></div>
|
<div class="stat"><b>{{TIME_SPAN}}</b><span>持续时长</span></div>
|
||||||
<div class="stat"><b>{{TOPIC_COUNT}}</b><span>主要话题</span></div>
|
<div class="stat"><b>{{TOPIC_COUNT}}</b><span>主要话题</span></div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import path from 'path'
|
|||||||
import {
|
import {
|
||||||
GroupReportExportRequest,
|
GroupReportExportRequest,
|
||||||
GroupReportExportResult,
|
GroupReportExportResult,
|
||||||
|
GroupReportMetadata,
|
||||||
ReportHeat
|
ReportHeat
|
||||||
} from '../shared/group-report'
|
} from '../shared/group-report'
|
||||||
|
import { resolveMd5, getGroupSnapshot } from './services/chat-service'
|
||||||
|
|
||||||
const TEMPLATE_NAME = 'mobile_daily_report.html'
|
const TEMPLATE_NAME = 'mobile_daily_report.html'
|
||||||
|
|
||||||
@@ -48,7 +50,7 @@ const imageMimeType = (contentType: string | null, source: string): string => {
|
|||||||
|
|
||||||
const embedAvatar = async (source: string | undefined, name: string): Promise<string> => {
|
const embedAvatar = async (source: string | undefined, name: string): Promise<string> => {
|
||||||
if (!source) return fallbackAvatar(name)
|
if (!source) return fallbackAvatar(name)
|
||||||
if (/^data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=]+$/i.test(source)) return source
|
if (/^data:image\/[a-z0-9.+/-]+;base64,[a-z0-9+/=]+$/i.test(source)) return source
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (/^https?:\/\//i.test(source)) {
|
if (/^https?:\/\//i.test(source)) {
|
||||||
@@ -84,6 +86,51 @@ const templatePath = (): string => {
|
|||||||
return found
|
return found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从群成员快照反推真头像,填进 metadata.avatars。
|
||||||
|
* - 没传 talker → 跳过(向后兼容)
|
||||||
|
* - talker 解析失败 / snapshot 拿不到 → 200 + warn,继续走 fallback
|
||||||
|
* - 客户端传的 avatars[name](非空)优先;否则从 snapshot 的 m_nsHeadImgUrl 补
|
||||||
|
* - 同名取首条(P2 风险:群里两人同名)
|
||||||
|
*/
|
||||||
|
const enrichAvatarsFromGroup = async (metadata: GroupReportMetadata): Promise<void> => {
|
||||||
|
if (!metadata.talker) return
|
||||||
|
|
||||||
|
const resolved = resolveMd5(metadata.talker)
|
||||||
|
if (!resolved) {
|
||||||
|
metadata.warnings = metadata.warnings ?? []
|
||||||
|
metadata.warnings.push(`enrich skipped: talker "${metadata.talker}" not found`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = getGroupSnapshot(resolved.md5)
|
||||||
|
if (!snapshot) {
|
||||||
|
metadata.warnings = metadata.warnings ?? []
|
||||||
|
metadata.warnings.push(
|
||||||
|
`enrich skipped: group snapshot not available for "${metadata.talker}"`
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = new Map<string, string>()
|
||||||
|
for (const member of snapshot.members) {
|
||||||
|
if (member.nickname && member.avatar && !index.has(member.nickname)) {
|
||||||
|
index.set(member.nickname, member.avatar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata.avatars = metadata.avatars ?? {}
|
||||||
|
for (const [name, url] of index) {
|
||||||
|
if (metadata.avatars[name]) continue
|
||||||
|
metadata.avatars[name] = url
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata.warnings = metadata.warnings ?? []
|
||||||
|
metadata.warnings.push(
|
||||||
|
`enriched ${index.size} member avatars from snapshot (${snapshot.memberCount} members)`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const heatClass = (heat: ReportHeat): string => {
|
const heatClass = (heat: ReportHeat): string => {
|
||||||
if (heat === '高') return 'hot'
|
if (heat === '高') return 'hot'
|
||||||
if (heat === '低') return 'blue'
|
if (heat === '低') return 'blue'
|
||||||
@@ -208,7 +255,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
|||||||
HERO_AVATARS: heroAvatars,
|
HERO_AVATARS: heroAvatars,
|
||||||
MESSAGE_COUNT: String(metadata.messageCount),
|
MESSAGE_COUNT: String(metadata.messageCount),
|
||||||
ACTIVE_USERS: String(metadata.activeUsers),
|
ACTIVE_USERS: String(metadata.activeUsers),
|
||||||
TIME_SPAN: escapeHtml(metadata.timeSpan),
|
TIME_SPAN: escapeHtml(metadata.timeSpan || ''),
|
||||||
TOPIC_COUNT: String(report.topics.length),
|
TOPIC_COUNT: String(report.topics.length),
|
||||||
TOPIC_CARDS: topicCards,
|
TOPIC_CARDS: topicCards,
|
||||||
RESOURCES_EMPTY_CLASS: report.resources.length ? '' : 'empty-section',
|
RESOURCES_EMPTY_CLASS: report.resources.length ? '' : 'empty-section',
|
||||||
@@ -280,6 +327,9 @@ export const exportGroupReport = async (
|
|||||||
request: GroupReportExportRequest
|
request: GroupReportExportRequest
|
||||||
): Promise<GroupReportExportResult> => {
|
): Promise<GroupReportExportResult> => {
|
||||||
try {
|
try {
|
||||||
|
// === enrich 在 render 之前:从群成员快照反推真头像 ===
|
||||||
|
await enrichAvatarsFromGroup(request.metadata)
|
||||||
|
|
||||||
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
|
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
|
||||||
await fs.ensureDir(outputDir)
|
await fs.ensureDir(outputDir)
|
||||||
const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_可视化长图`
|
const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_可视化长图`
|
||||||
@@ -288,7 +338,13 @@ export const exportGroupReport = async (
|
|||||||
const html = await renderReportHtml(request)
|
const html = await renderReportHtml(request)
|
||||||
await fs.writeFile(htmlPath, html, 'utf8')
|
await fs.writeFile(htmlPath, html, 'utf8')
|
||||||
const imageDataUrl = await captureFullPage(htmlPath, pngPath)
|
const imageDataUrl = await captureFullPage(htmlPath, pngPath)
|
||||||
return { success: true, htmlPath, pngPath, imageDataUrl }
|
return {
|
||||||
|
success: true,
|
||||||
|
htmlPath,
|
||||||
|
pngPath,
|
||||||
|
imageDataUrl,
|
||||||
|
warnings: request.metadata.warnings?.length ? request.metadata.warnings : undefined
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[GroupReport] export failed:', error)
|
console.error('[GroupReport] export failed:', error)
|
||||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ app.whenReady().then(async () => {
|
|||||||
return databaseKeyStore.save(clipboardKey)
|
return databaseKeyStore.save(clipboardKey)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('key:saveDbKey', async (_, key: string) => databaseKeyStore.save(String(key || '')))
|
||||||
|
|
||||||
ipcMain.handle('key:clearSavedDbKey', async () => databaseKeyStore.clear())
|
ipcMain.handle('key:clearSavedDbKey', async () => databaseKeyStore.clear())
|
||||||
|
|
||||||
ipcMain.handle('key:autoGetDbKey', async (event) => {
|
ipcMain.handle('key:autoGetDbKey', async (event) => {
|
||||||
|
|||||||
Vendored
+1
@@ -78,6 +78,7 @@ declare global {
|
|||||||
warning?: string
|
warning?: string
|
||||||
}>
|
}>
|
||||||
pasteAndSaveDbKey: () => Promise<{ success: boolean; key?: string; error?: string }>
|
pasteAndSaveDbKey: () => Promise<{ success: boolean; key?: string; error?: string }>
|
||||||
|
saveDbKey: (key: string) => Promise<{ success: boolean; key?: string; error?: string }>
|
||||||
clearSavedDbKey: () => Promise<{ success: boolean; error?: string }>
|
clearSavedDbKey: () => Promise<{ success: boolean; error?: string }>
|
||||||
onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => () => void
|
onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => () => void
|
||||||
onDbKeyStatus: (callback: (payload: { message: string }) => void) => () => void
|
onDbKeyStatus: (callback: (payload: { message: string }) => void) => () => void
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const api = {
|
|||||||
getSavedDbKey: () => ipcRenderer.invoke('key:getSavedDbKey'),
|
getSavedDbKey: () => ipcRenderer.invoke('key:getSavedDbKey'),
|
||||||
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
|
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
|
||||||
pasteAndSaveDbKey: () => ipcRenderer.invoke('key:pasteAndSaveDbKey'),
|
pasteAndSaveDbKey: () => ipcRenderer.invoke('key:pasteAndSaveDbKey'),
|
||||||
|
saveDbKey: (key: string) => ipcRenderer.invoke('key:saveDbKey', key),
|
||||||
clearSavedDbKey: () => ipcRenderer.invoke('key:clearSavedDbKey'),
|
clearSavedDbKey: () => ipcRenderer.invoke('key:clearSavedDbKey'),
|
||||||
onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => {
|
onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => {
|
||||||
const listener = (
|
const listener = (
|
||||||
|
|||||||
+92
-27
@@ -107,19 +107,84 @@ function App(): React.ReactElement {
|
|||||||
const [showSettings, setShowSettings] = useState(false)
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
const [selfInfo, setSelfInfo] = useState<SelfInfo | null>(null)
|
const [selfInfo, setSelfInfo] = useState<SelfInfo | null>(null)
|
||||||
const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false)
|
const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false)
|
||||||
|
const [bootState, setBootState] = useState<'loading' | 'connecting' | 'login'>('loading')
|
||||||
|
const [autoConnectSource, setAutoConnectSource] = useState<'env' | 'saved' | null>(null)
|
||||||
const currentGroupSnapshotRef = React.useRef<GroupSnapshot | null>(null)
|
const currentGroupSnapshotRef = React.useRef<GroupSnapshot | null>(null)
|
||||||
const syntheticGroupMessagesRef = React.useRef<Record<string, Message[]>>({})
|
const syntheticGroupMessagesRef = React.useRef<Record<string, Message[]>>({})
|
||||||
|
|
||||||
|
const refreshSelfInfo = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const result = await window.api.getSelf()
|
||||||
|
if (result.ready) {
|
||||||
|
setSelfInfo(result.info)
|
||||||
|
} else {
|
||||||
|
setSelfInfo(null)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[SelfInfo] 加载失败:', error)
|
||||||
|
setSelfInfo(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadContacts = async (): Promise<void> => {
|
||||||
|
const list = await window.api.getContacts()
|
||||||
|
setContacts(list)
|
||||||
|
setFilteredContacts(list)
|
||||||
|
}
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
void window.api.getSavedDbKey().then((result) => {
|
const attemptAutoConnect = async (): Promise<void> => {
|
||||||
if (!active) return
|
// 优先级 1:构建期环境变量 VITE_DB_KEY(本地开发/打包时硬编码的密钥)
|
||||||
if (result.success && result.key) {
|
const envKey = String(import.meta.env.VITE_DB_KEY || '').trim()
|
||||||
setDbKey(result.key)
|
// 优先级 2:上一次保存到 safeStorage 的密钥
|
||||||
setDbKeyStatus('已加载安全保存的密钥')
|
let savedKey = ''
|
||||||
setDbKeyStatusKind('success')
|
if (!envKey) {
|
||||||
|
const result = await window.api.getSavedDbKey()
|
||||||
|
if (result.success && result.key) savedKey = result.key
|
||||||
}
|
}
|
||||||
})
|
const key = envKey || savedKey
|
||||||
|
if (!key) {
|
||||||
|
if (active) setBootState('login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (active) {
|
||||||
|
setBootState('connecting')
|
||||||
|
setDbKey(key)
|
||||||
|
setAutoConnectSource(envKey ? 'env' : 'saved')
|
||||||
|
setDbKeyStatus(
|
||||||
|
envKey ? '检测到环境变量中的密钥,正在自动连接...' : '已加载安全保存的密钥,正在自动连接...'
|
||||||
|
)
|
||||||
|
setDbKeyStatusKind('normal')
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await window.api.initDb(key)
|
||||||
|
if (!active) return
|
||||||
|
const success = typeof result === 'boolean' ? result : result.success
|
||||||
|
if (success) {
|
||||||
|
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
|
||||||
|
setIsAuthenticated(true)
|
||||||
|
setDbKeyStatus('已自动连接')
|
||||||
|
setDbKeyStatusKind('success')
|
||||||
|
await loadContacts()
|
||||||
|
void refreshSelfInfo()
|
||||||
|
} else {
|
||||||
|
const error = typeof result === 'boolean' ? '' : result.error
|
||||||
|
setDbKeyStatus(
|
||||||
|
`自动连接失败,请重新输入${error ? `: ${error}` : ''}`
|
||||||
|
)
|
||||||
|
setDbKeyStatusKind('error')
|
||||||
|
setBootState('login')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!active) return
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
setDbKeyStatus(`自动连接失败: ${message}`)
|
||||||
|
setDbKeyStatusKind('error')
|
||||||
|
setBootState('login')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void attemptAutoConnect()
|
||||||
const unsubscribe = window.api.onDbKeyStatus(({ message }) => {
|
const unsubscribe = window.api.onDbKeyStatus(({ message }) => {
|
||||||
if (!active) return
|
if (!active) return
|
||||||
setDbKeyStatus(message)
|
setDbKeyStatus(message)
|
||||||
@@ -146,6 +211,8 @@ function App(): React.ReactElement {
|
|||||||
if (success) {
|
if (success) {
|
||||||
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
|
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
|
||||||
setIsAuthenticated(true)
|
setIsAuthenticated(true)
|
||||||
|
// 手动输入也持久化,下次启动可自动连接(参考 WeFlow)
|
||||||
|
void window.api.saveDbKey(keyToUse).catch(() => undefined)
|
||||||
loadContacts()
|
loadContacts()
|
||||||
void refreshSelfInfo()
|
void refreshSelfInfo()
|
||||||
} else {
|
} else {
|
||||||
@@ -158,20 +225,6 @@ function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshSelfInfo = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const result = await window.api.getSelf()
|
|
||||||
if (result.ready) {
|
|
||||||
setSelfInfo(result.info)
|
|
||||||
} else {
|
|
||||||
setSelfInfo(null)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[SelfInfo] 加载失败:', error)
|
|
||||||
setSelfInfo(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const logGroupSnapshot = React.useCallback(
|
const logGroupSnapshot = React.useCallback(
|
||||||
async (contact: Contact | null, reason: string): Promise<GroupSnapshot | null> => {
|
async (contact: Contact | null, reason: string): Promise<GroupSnapshot | null> => {
|
||||||
if (!contact || contact.type !== 'group') return null
|
if (!contact || contact.type !== 'group') return null
|
||||||
@@ -251,12 +304,6 @@ function App(): React.ReactElement {
|
|||||||
setDbKeyStatusKind('normal')
|
setDbKeyStatusKind('normal')
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadContacts = async (): Promise<void> => {
|
|
||||||
const list = await window.api.getContacts()
|
|
||||||
setContacts(list)
|
|
||||||
setFilteredContacts(list)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDateRangeParams = (
|
const getDateRangeParams = (
|
||||||
range: string
|
range: string
|
||||||
): { startTime: number | undefined; endTime: number | undefined } => {
|
): { startTime: number | undefined; endTime: number | undefined } => {
|
||||||
@@ -434,6 +481,24 @@ function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}, [resize, stopResizing])
|
}, [resize, stopResizing])
|
||||||
|
|
||||||
|
if (!isAuthenticated && bootState !== 'login') {
|
||||||
|
return (
|
||||||
|
<div className="boot-splash">
|
||||||
|
<div className="boot-splash-spinner" aria-hidden />
|
||||||
|
<div className="boot-splash-title">
|
||||||
|
{bootState === 'connecting' ? '正在自动连接数据库...' : '正在准备...'}
|
||||||
|
</div>
|
||||||
|
<div className="boot-splash-subtitle">
|
||||||
|
{bootState === 'connecting'
|
||||||
|
? autoConnectSource === 'env'
|
||||||
|
? '检测到环境变量中的密钥'
|
||||||
|
: '使用上次安全保存的密钥'
|
||||||
|
: 'WechatExplorer'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<div className="login-modal">
|
<div className="login-modal">
|
||||||
|
|||||||
@@ -1273,6 +1273,55 @@ body {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 启动自动连接 Splash */
|
||||||
|
.boot-splash {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 14px;
|
||||||
|
background: linear-gradient(180deg, #f5f7f8 0%, #eceff1 100%);
|
||||||
|
z-index: 2000;
|
||||||
|
animation: boot-splash-fade-in 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes boot-splash-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.boot-splash-spinner {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid rgba(7, 193, 96, 0.18);
|
||||||
|
border-top-color: #07c160;
|
||||||
|
animation: boot-splash-spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes boot-splash-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.boot-splash-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1f2429;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boot-splash-subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6f767c;
|
||||||
|
}
|
||||||
|
|
||||||
/* 设置面板 */
|
/* 设置面板 */
|
||||||
.settings-overlay {
|
.settings-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -169,9 +169,21 @@ export const buildGroupReportInput = (
|
|||||||
const dateRange = sameDay
|
const dateRange = sameDay
|
||||||
? `${startDate} ${localTime(firstTimestamp)}-${localTime(lastTimestamp)}`
|
? `${startDate} ${localTime(firstTimestamp)}-${localTime(lastTimestamp)}`
|
||||||
: `${startDate} ${localTime(firstTimestamp)} 至 ${endDate} ${localTime(lastTimestamp)}`
|
: `${startDate} ${localTime(firstTimestamp)} 至 ${endDate} ${localTime(lastTimestamp)}`
|
||||||
const timeSpan = sameDay
|
// 模板"持续时长"格子:首条到末条消息的时长,紧凑半角格式
|
||||||
? `${Math.max(1, Math.ceil((lastTimestamp - firstTimestamp) / 3600000))}小时`
|
const durationMs = Math.max(0, lastTimestamp - firstTimestamp)
|
||||||
: `${Math.max(1, Math.ceil((lastTimestamp - firstTimestamp) / 86400000))}天`
|
const durationHours = durationMs / 3600000
|
||||||
|
const timeSpan = (() => {
|
||||||
|
if (sameDay) {
|
||||||
|
if (durationHours < 1) {
|
||||||
|
const minutes = Math.max(1, Math.round(durationMs / 60000))
|
||||||
|
return `${minutes} min`
|
||||||
|
}
|
||||||
|
const hours = Math.max(1, Math.ceil(durationHours))
|
||||||
|
return `${hours} h`
|
||||||
|
}
|
||||||
|
const days = Math.max(1, Math.ceil(durationMs / 86400000))
|
||||||
|
return `${days} d`
|
||||||
|
})()
|
||||||
const contactName = contact?.m_nsNickName || ''
|
const contactName = contact?.m_nsNickName || ''
|
||||||
const groupName = contactName && !isInternalIdentifier(contactName) ? contactName : '未命名会话'
|
const groupName = contactName && !isInternalIdentifier(contactName) ? contactName : '未命名会话'
|
||||||
const metadata: GroupReportMetadata = {
|
const metadata: GroupReportMetadata = {
|
||||||
|
|||||||
@@ -76,6 +76,13 @@ export interface GroupReportMetadata {
|
|||||||
footerNote: string
|
footerNote: string
|
||||||
heroParticipants: string[]
|
heroParticipants: string[]
|
||||||
avatars: Record<string, string | undefined>
|
avatars: Record<string, string | undefined>
|
||||||
|
// === 新增(可选,向后兼容) ===
|
||||||
|
/** 群昵称 / wxid / md5,服务端用来反推真头像(从 getGroupSnapshot) */
|
||||||
|
talker?: string
|
||||||
|
/** 预留,与 /api/v1/chatlog 的 time 参数同格式 */
|
||||||
|
timeRange?: string
|
||||||
|
/** 服务端写回,告知 client enrich 失败/部分缺失 */
|
||||||
|
warnings?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupReportExportRequest {
|
export interface GroupReportExportRequest {
|
||||||
@@ -88,5 +95,6 @@ export interface GroupReportExportResult {
|
|||||||
htmlPath?: string
|
htmlPath?: string
|
||||||
pngPath?: string
|
pngPath?: string
|
||||||
imageDataUrl?: string
|
imageDataUrl?: string
|
||||||
|
warnings?: string[]
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user