mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 11:37:06 +08:00
merge: feat/report 集成进 feat/newReport (M1)
合并 feat/report 的日报功能 + main 的图片识别基建: - 保留 main 分支的图片识别能力(ai-provider-service + key-store + settings/ai-model) - 保留 feat/report 的日报功能(ChatWindow.tsx + group-report* + mobile_daily_report.html) - index.ts/preload 同时保留两边的 IPC handler(api:chat / ai:testVision / report:export / db:getImage) 冲突解决: - main/index.ts ai:chat:采用 main 版本(AIProviderService) - group-report-service.ts values:采用 feat/report 版本(完整 hero/section 占位符) - 整文件替换:ChatWindow.tsx / group-report.ts / mobile_daily_report.html (采用 feat/report 版本,与 group-report-facts.ts 协同)
This commit is contained in:
+1
-1
@@ -8,7 +8,7 @@ VITE_AUTO_LOGIN=false
|
||||
# AI API Configuration (Optional, can be entered in UI)
|
||||
VITE_DEEPSEEK_API_KEY=
|
||||
VITE_AI_BASE_URL=https://api.deepseek.com
|
||||
VITE_AI_MODEL=deepseek-chat
|
||||
VITE_AI_MODEL=deepseek-v4-flash
|
||||
|
||||
# Message types to filter out (comma separated). Empty means show all message types.
|
||||
VITE_FILTER_MSG_TYPES=
|
||||
|
||||
@@ -18,10 +18,13 @@ macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。
|
||||
- **AI 智能总结**: 支持多模型服务配置(DeepSeek/GPT-4o/Claude/Moonshot),一键总结群聊精华内容,生成话题报告。
|
||||
- **群聊日报生成**: 支持围绕群聊内容生成日报,通常会覆盖以下模块中的部分或全部内容:
|
||||
- **今日讨论热点**: 梳理群内主要话题,支持热度标签。
|
||||
- **一句话速览**: 首屏突出今日核心结论与待跟进事项。
|
||||
- **实用信息与资源**: 提取分享的链接、资源等信息。
|
||||
- **重要消息汇总**: 标记并展示重要消息,带发送者头像。
|
||||
- **有趣对话或金句**: 收录群内的精彩对话。
|
||||
- **问题与解答**: 整理群内的问答内容。
|
||||
- **尚未解决 / 今日剧情线**: 更适合工作群和项目群的回顾与跟进。
|
||||
- **今日群相册 / 语音时长榜 / 临时群友称号**: 让图片、语音和氛围型内容也能参与日报。
|
||||
- **群内数据可视化**: 消息热度条形图、话唠榜 TOP5、活跃时间线。
|
||||
- **词云/关键词**: 可视化展示群聊关键词。
|
||||
- **图片生成**: 将 AI 总结的内容生成精美图片,方便分享。
|
||||
|
||||
@@ -141,12 +141,31 @@ GET 用于读取数据,`POST /api/v1/report` 用于生成群日报(HTML + 长图
|
||||
"qa": [
|
||||
{ "question": "Q", "answer": "A", "answerer": "解答人(可选)" }
|
||||
],
|
||||
"unresolved": [
|
||||
{ "question": "待跟进问题", "owner": "相关人(可选)", "status": "待跟进", "note": "为什么还没结束" }
|
||||
],
|
||||
"storylines": [
|
||||
{ "title": "剧情线", "stages": [{ "time": "10:12", "event": "提出问题" }], "result": "可选结果" }
|
||||
],
|
||||
"reversals": [
|
||||
{ "topic": "某话题", "initialView": "最初判断", "finalView": "最终判断", "note": "可选说明" }
|
||||
],
|
||||
"participantChains": [
|
||||
{ "topic": "某话题", "chain": ["A 提出", "B 补充", "C 收尾"], "note": "可选说明" }
|
||||
],
|
||||
"analytics": {
|
||||
"topicHeat": [{ "topic": "话题1", "score": 9.5 }],
|
||||
"activeTimeline": "10:00-12:00 为最活跃时段",
|
||||
"topSpeakers": [{ "name": "张三", "count": 58 }]
|
||||
"topSpeakers": [{ "name": "张三", "count": 58 }],
|
||||
"voiceLeaderboard": [{ "sender": "张三", "count": 3, "durationSec": 97 }]
|
||||
},
|
||||
"keywords": ["高频词1", "高频词2"]
|
||||
"keywords": ["高频词1", "高频词2"],
|
||||
"hero": {
|
||||
"headline": "一句抓重点的日报标题",
|
||||
"summary": "一句概览",
|
||||
"keyTakeaway": "最重要结论",
|
||||
"pendingNote": "待跟进事项"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"groupName": "技术交流",
|
||||
|
||||
+386
-104
@@ -27,13 +27,13 @@
|
||||
.report {
|
||||
width: 430px;
|
||||
margin: 0 auto;
|
||||
padding: 22px 14px 34px;
|
||||
padding: 20px 14px 34px;
|
||||
}
|
||||
.hero,
|
||||
.card,
|
||||
.section {
|
||||
.section,
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
.hero {
|
||||
@@ -41,32 +41,35 @@
|
||||
}
|
||||
.hero-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
.hero-top > div:first-child {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: 23px;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 8px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.sub {
|
||||
margin-top: 8px;
|
||||
color: #667085;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.record-note {
|
||||
color: #485465;
|
||||
font-weight: 650;
|
||||
}
|
||||
.overview {
|
||||
margin-top: 2px;
|
||||
.mode-tag {
|
||||
display: inline-flex;
|
||||
margin-top: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: #eef8f2;
|
||||
color: #07a352;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.avatar-grid {
|
||||
width: 58px;
|
||||
@@ -83,6 +86,49 @@
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.hero-headline {
|
||||
margin-top: 14px;
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #edf9f1 0%, #f7fbf8 100%);
|
||||
}
|
||||
.hero-headline b {
|
||||
display: block;
|
||||
font-size: 17px;
|
||||
color: #076c39;
|
||||
}
|
||||
.hero-headline p {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
color: #1f2933;
|
||||
}
|
||||
.hero-inline-notes {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.hero-note,
|
||||
.hero-status {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.hero-status {
|
||||
margin-top: 10px;
|
||||
background: #f7faf9;
|
||||
color: #076c39;
|
||||
font-weight: 700;
|
||||
}
|
||||
.hero-note.takeaway {
|
||||
background: #eef8f2;
|
||||
color: #076c39;
|
||||
}
|
||||
.hero-note.pending {
|
||||
background: #fff8e8;
|
||||
color: #8a5a00;
|
||||
}
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
@@ -94,14 +140,11 @@
|
||||
border-radius: 12px;
|
||||
padding: 10px 6px;
|
||||
text-align: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stat b {
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
color: #07a352;
|
||||
white-space: nowrap;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.stat span {
|
||||
@@ -109,16 +152,16 @@
|
||||
color: #667085;
|
||||
}
|
||||
.section {
|
||||
margin-top: 14px;
|
||||
margin-top: 18px;
|
||||
padding: 18px;
|
||||
}
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 18px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 19px;
|
||||
font-weight: 900;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.section-title::before {
|
||||
content: '';
|
||||
@@ -127,11 +170,23 @@
|
||||
border-radius: 99px;
|
||||
background: #07c160;
|
||||
}
|
||||
.section-subtitle {
|
||||
margin: 10px 0 6px;
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.section-more {
|
||||
margin-top: 10px;
|
||||
color: #98a2b3;
|
||||
font-size: 11px;
|
||||
text-align: right;
|
||||
}
|
||||
.card {
|
||||
padding: 14px;
|
||||
margin-top: 10px;
|
||||
box-shadow: none;
|
||||
border: 1px solid #edf0f2;
|
||||
box-shadow: none;
|
||||
}
|
||||
.topic-title-row {
|
||||
display: flex;
|
||||
@@ -140,17 +195,17 @@
|
||||
gap: 8px;
|
||||
}
|
||||
.topic-title-row h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.35;
|
||||
margin: 0;
|
||||
font-weight: 850;
|
||||
}
|
||||
.heat,
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: #eef8f2;
|
||||
color: #07a352;
|
||||
font-size: 11px;
|
||||
@@ -165,19 +220,44 @@
|
||||
background: #eef5ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
.red {
|
||||
background: #fff1f0;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
.topic-meta {
|
||||
margin-top: 6px;
|
||||
color: #8a94a6;
|
||||
font-size: 12px;
|
||||
}
|
||||
.card p {
|
||||
margin: 10px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
.topic-conclusions {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.topic-conclusion {
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
background: #edf9f1;
|
||||
color: #076c39;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
font-weight: 700;
|
||||
}
|
||||
.topic-inline-image {
|
||||
display: grid;
|
||||
grid-template-columns: 76px 1fr;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 12px;
|
||||
background: #f7faf9;
|
||||
}
|
||||
.topic-inline-image img {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.participants {
|
||||
display: flex;
|
||||
@@ -189,7 +269,6 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
background: #f6f8fa;
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px 3px 3px;
|
||||
@@ -198,7 +277,6 @@
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.person-chip b {
|
||||
max-width: 58px;
|
||||
@@ -220,17 +298,6 @@
|
||||
background: #f2f4f7;
|
||||
color: #667085;
|
||||
}
|
||||
.resource {
|
||||
padding: 11px 12px;
|
||||
background: #f7f8fa;
|
||||
border-radius: 12px;
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.resource b {
|
||||
color: #1677ff;
|
||||
}
|
||||
.important-card {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -255,9 +322,9 @@
|
||||
color: #1f2933;
|
||||
}
|
||||
.important-text {
|
||||
margin-top: 5px;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.important-note {
|
||||
margin-top: 8px;
|
||||
@@ -269,6 +336,34 @@
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.action-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.action-card {
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
}
|
||||
.todo-card {
|
||||
background: #eef5ff;
|
||||
}
|
||||
.unresolved-card {
|
||||
background: #fff8e8;
|
||||
}
|
||||
.action-card b {
|
||||
display: block;
|
||||
color: #1f2933;
|
||||
font-size: 14px;
|
||||
}
|
||||
.action-card div {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: #485465;
|
||||
}
|
||||
.action-note {
|
||||
color: #667085;
|
||||
}
|
||||
.chat-block {
|
||||
background: #f0f2f5;
|
||||
border-radius: 14px;
|
||||
@@ -299,49 +394,126 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
.quote-note {
|
||||
background: #fff8e1;
|
||||
border-radius: 10px;
|
||||
padding: 9px 10px;
|
||||
margin-top: 10px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 10px;
|
||||
background: #fff8e1;
|
||||
color: #8a5a00;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.qa-card {
|
||||
background: #f8fafc;
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
.qa-card,
|
||||
.resource {
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.qa-card b {
|
||||
.qa-card b,
|
||||
.resource b {
|
||||
display: block;
|
||||
color: #1f2933;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.qa-card div {
|
||||
.qa-card div,
|
||||
.resource {
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: #485465;
|
||||
}
|
||||
.bar-row {
|
||||
.storyline-card,
|
||||
.chain-card {
|
||||
background: #f8fafc;
|
||||
}
|
||||
.storyline-steps {
|
||||
display: grid;
|
||||
grid-template-columns: 82px 1fr;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 9px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.storyline-step {
|
||||
display: grid;
|
||||
grid-template-columns: 50px 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.storyline-step span {
|
||||
color: #8a94a6;
|
||||
font-size: 12px;
|
||||
}
|
||||
.bar {
|
||||
height: 10px;
|
||||
background: #edf1f5;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
.storyline-step b {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: #07c160;
|
||||
.chain-flow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.chain-flow span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 9px;
|
||||
border-radius: 999px;
|
||||
background: #eef8f2;
|
||||
color: #076c39;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.chain-flow i {
|
||||
font-style: normal;
|
||||
color: #98a2b3;
|
||||
}
|
||||
.gallery-card {
|
||||
display: grid;
|
||||
grid-template-columns: 112px 1fr;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
background: #f7faf9;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.gallery-image {
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
.gallery-stats {
|
||||
display: inline-flex;
|
||||
margin-top: 7px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: #eef5ff;
|
||||
color: #1677ff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.badge-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.badge-card {
|
||||
background: linear-gradient(180deg, #fdfdfd 0%, #f6fbf8 100%);
|
||||
border: 1px solid #edf0f2;
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
}
|
||||
.badge-card b {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.badge-card p {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.data-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.rank {
|
||||
display: flex;
|
||||
@@ -350,6 +522,9 @@
|
||||
padding: 9px 0;
|
||||
border-bottom: 1px solid #eef0f2;
|
||||
}
|
||||
.rank:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.rank img {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
@@ -402,7 +577,58 @@
|
||||
color: #8a94a6;
|
||||
}
|
||||
.empty-section {
|
||||
display: none;
|
||||
display: none !important;
|
||||
}
|
||||
.compact .report {
|
||||
padding-top: 18px;
|
||||
}
|
||||
.compact .section {
|
||||
margin-top: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
.compact .card {
|
||||
padding: 11px;
|
||||
}
|
||||
.compact .important-card,
|
||||
.compact .gallery-card,
|
||||
.compact .chat-block {
|
||||
padding: 10px;
|
||||
}
|
||||
.compact .section-title {
|
||||
margin-bottom: 9px;
|
||||
font-size: 17px;
|
||||
}
|
||||
.compact .hero-headline p,
|
||||
.compact .card p,
|
||||
.compact .important-text,
|
||||
.compact .chat-bubble {
|
||||
line-height: 1.5;
|
||||
}
|
||||
.compact .participants,
|
||||
.compact .keywords,
|
||||
.compact .hero-inline-notes {
|
||||
gap: 6px;
|
||||
}
|
||||
.compact .topic-conclusions {
|
||||
gap: 6px;
|
||||
}
|
||||
.compact .topic-inline-image {
|
||||
grid-template-columns: 64px 1fr;
|
||||
padding: 8px;
|
||||
}
|
||||
.compact .topic-inline-image img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
.compact .stats {
|
||||
gap: 6px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.compact .stat {
|
||||
padding: 8px 6px;
|
||||
}
|
||||
.compact .stat b {
|
||||
font-size: 17px;
|
||||
}
|
||||
@media (max-width: 430px) {
|
||||
html,
|
||||
@@ -414,83 +640,139 @@
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
.stats {
|
||||
gap: 6px;
|
||||
}
|
||||
.stat b {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<body class="{{REPORT_MODE_CLASS}}">
|
||||
<main class="report">
|
||||
<header class="hero">
|
||||
<div class="hero-top">
|
||||
<div>
|
||||
<h1>{{GROUP_NAME}}日报</h1>
|
||||
<div class="sub">
|
||||
<div>{{DATE_RANGE}}</div>
|
||||
<div class="record-note">{{RECORD_NOTE}}</div>
|
||||
<div class="overview">{{OVERVIEW}}</div>
|
||||
</div>
|
||||
<div class="sub">{{DATE_RANGE}}<br />{{RECORD_NOTE}}</div>
|
||||
<div class="mode-tag">{{REPORT_MODE_LABEL}}</div>
|
||||
</div>
|
||||
<div class="avatar-grid">{{HERO_AVATARS}}</div>
|
||||
</div>
|
||||
<div class="hero-headline">
|
||||
<b>{{HERO_HEADLINE}}</b>
|
||||
<p>{{HERO_SUMMARY}}</p>
|
||||
</div>
|
||||
<div class="hero-status {{HERO_STATUS_EMPTY_CLASS}}">{{HERO_STATUS_LINE}}</div>
|
||||
<div class="hero-inline-notes">
|
||||
<div class="hero-note takeaway {{HERO_TAKEAWAY_EMPTY_CLASS}}">{{HERO_TAKEAWAY}}</div>
|
||||
<div class="hero-note pending {{HERO_PENDING_EMPTY_CLASS}}">{{HERO_PENDING}}</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat"><b>{{MESSAGE_COUNT}}</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>{{TOPIC_COUNT}}</b><span>主要话题</span></div>
|
||||
<div class="stat"><b>{{TOPIC_COUNT}}</b><span>话题数</span></div>
|
||||
<div class="stat"><b>{{MEDIA_COUNT}}</b><span>媒体消息</span></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="section topics">
|
||||
<section class="section {{TOPICS_EMPTY_CLASS}}">
|
||||
<div class="section-title">今日讨论热点</div>
|
||||
{{TOPIC_CARDS}}
|
||||
{{TOPICS_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section resources {{RESOURCES_EMPTY_CLASS}}">
|
||||
<div class="section-title">实用信息与资源</div>
|
||||
{{RESOURCE_ITEMS}}
|
||||
</section>
|
||||
|
||||
<section class="section messages {{MESSAGES_EMPTY_CLASS}}">
|
||||
<div class="section-title">重要消息汇总</div>
|
||||
<section class="section {{MESSAGES_EMPTY_CLASS}}">
|
||||
<div class="section-title">重要消息</div>
|
||||
{{IMPORTANT_MESSAGES}}
|
||||
{{MESSAGES_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section quotes {{QUOTES_EMPTY_CLASS}}">
|
||||
<div class="section-title">有趣对话或金句</div>
|
||||
<section class="section {{ACTIONS_EMPTY_CLASS}}">
|
||||
<div class="section-title">待办事项和未解决问题</div>
|
||||
<div class="section-subtitle {{TODO_EMPTY_CLASS}}">待办事项</div>
|
||||
<div class="action-grid {{TODO_EMPTY_CLASS}}">{{TODO_CARDS}}</div>
|
||||
<div class="section-subtitle {{UNRESOLVED_EMPTY_CLASS}}">尚未解决</div>
|
||||
<div class="action-grid {{UNRESOLVED_EMPTY_CLASS}}">{{UNRESOLVED_CARDS}}</div>
|
||||
{{ACTIONS_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{QUOTES_EMPTY_CLASS}}">
|
||||
<div class="section-title">今日名场面</div>
|
||||
{{QUOTE_BLOCKS}}
|
||||
{{QUOTES_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section qa {{QA_EMPTY_CLASS}}">
|
||||
<div class="section-title">问题与解答</div>
|
||||
{{QA_CARDS}}
|
||||
</section>
|
||||
|
||||
<section class="section analytics">
|
||||
<div class="section-title">群内数据可视化</div>
|
||||
{{HEAT_BARS}}
|
||||
<section class="section {{ANALYTICS_EMPTY_CLASS}}">
|
||||
<div class="section-title">今日群数据</div>
|
||||
<div class="data-grid">
|
||||
<div class="card">
|
||||
<div class="muted" style="font-size: 12px; margin-bottom: 6px">
|
||||
话唠榜 TOP5(基于已读取记录估算)
|
||||
</div>
|
||||
<div class="muted" style="font-size: 12px; margin-bottom: 6px">话唠榜 TOP5</div>
|
||||
{{RANK_ITEMS}}
|
||||
</div>
|
||||
<div class="card">
|
||||
<p><b>活跃时间线:</b>{{ACTIVITY_TIMELINE}}</p>
|
||||
<p><b>最活跃时段:</b>{{ACTIVITY_TIMELINE}}</p>
|
||||
<p><b>今日状态:</b>形成 {{CONCLUSION_COUNT}} 个结论,待办 {{TODO_COUNT}} 项,未解决 {{UNRESOLVED_COUNT}} 项。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section cloud">
|
||||
<div class="section-title">词云/关键词</div>
|
||||
<section class="section {{KEYWORDS_EMPTY_CLASS}}">
|
||||
<div class="section-title">关键词</div>
|
||||
<div class="cloud-tags">{{CLOUD_TAGS}}</div>
|
||||
{{KEYWORDS_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{RESOURCES_EMPTY_CLASS}}">
|
||||
<div class="section-title">实用信息与资源</div>
|
||||
{{RESOURCE_ITEMS}}
|
||||
{{RESOURCES_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{QA_EMPTY_CLASS}}">
|
||||
<div class="section-title">问题与解答</div>
|
||||
{{QA_CARDS}}
|
||||
{{QA_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{STORYLINES_EMPTY_CLASS}}">
|
||||
<div class="section-title">今日剧情时间线</div>
|
||||
{{STORYLINE_CARDS}}
|
||||
{{STORYLINES_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{REVERSALS_EMPTY_CLASS}}">
|
||||
<div class="section-title">群聊反转现场</div>
|
||||
{{REVERSAL_CARDS}}
|
||||
{{REVERSALS_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{GALLERY_EMPTY_CLASS}}">
|
||||
<div class="section-title">今日群相册</div>
|
||||
{{GALLERY_CARDS}}
|
||||
{{GALLERY_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{VOICE_EMPTY_CLASS}}">
|
||||
<div class="section-title">语音之最</div>
|
||||
{{VOICE_CARDS}}
|
||||
{{VOICE_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{VOICE_RANK_EMPTY_CLASS}}">
|
||||
<div class="section-title">语音时长榜</div>
|
||||
<div class="card">{{VOICE_RANK_CARDS}}</div>
|
||||
</section>
|
||||
|
||||
<section class="section {{BADGES_EMPTY_CLASS}}">
|
||||
<div class="section-title">今日临时人设</div>
|
||||
<div class="badge-grid">{{BADGE_CARDS}}</div>
|
||||
{{BADGES_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{CHAINS_EMPTY_CLASS}}">
|
||||
<div class="section-title">话题参与链路</div>
|
||||
{{CHAIN_CARDS}}
|
||||
{{CHAINS_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<footer class="footer">
|
||||
数据来源:WechatExplorer · 微信群聊记录<br />
|
||||
数据来源:微信群聊记录<br />
|
||||
生成时间:{{GENERATED_AT}}<br />
|
||||
{{FOOTER_NOTE}}
|
||||
</footer>
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const templatePath = path.join(root, 'resources', 'mobile_daily_report.html')
|
||||
const outputDir = path.join(os.tmpdir(), 'wechatexplorer-report-fixtures')
|
||||
|
||||
const escapeHtml = (value) =>
|
||||
String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
const replacePlaceholder = (html, key, value) => html.replaceAll(`{{${key}}}`, value)
|
||||
|
||||
const avatarSvg = (label, color) =>
|
||||
`data:image/svg+xml;base64,${Buffer.from(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96"><rect width="96" height="96" rx="18" fill="${color}"/><text x="48" y="58" text-anchor="middle" font-family="PingFang SC, sans-serif" font-size="36" fill="#0f172a">${label}</text></svg>`
|
||||
).toString('base64')}`
|
||||
|
||||
const localImagePath = '/Users/Wxw_/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/a969409112_d784/temp/RWTemp/2026-07/94ce24699a5a1d539c00a37ec8ace755.png'
|
||||
const sampleImage = fs.existsSync(localImagePath)
|
||||
? `data:image/png;base64,${fs.readFileSync(localImagePath).toString('base64')}`
|
||||
: avatarSvg('图', '#dbeafe')
|
||||
|
||||
const avatars = {
|
||||
阿宇: avatarSvg('宇', '#dcfce7'),
|
||||
老周: avatarSvg('周', '#e0f2fe'),
|
||||
小李: avatarSvg('李', '#fef3c7'),
|
||||
'we water': avatarSvg('W', '#ede9fe'),
|
||||
佩佩: avatarSvg('佩', '#fee2e2')
|
||||
}
|
||||
|
||||
const heroNames = ['阿宇', '老周', '小李', 'we water']
|
||||
const heroAvatars = heroNames
|
||||
.map((name) => `<img src="${avatars[name]}" alt="${name}">`)
|
||||
.join('')
|
||||
|
||||
const compactRequest = {
|
||||
metadata: {
|
||||
groupName: '技术交流',
|
||||
reportDate: '2026-07-10',
|
||||
dateRange: '2026-07-10 09:12-19:48',
|
||||
messageCount: 382,
|
||||
activeUsers: 47,
|
||||
imageCount: 9,
|
||||
voiceCount: 5,
|
||||
stickerCount: 14,
|
||||
mediaMessageCount: 28,
|
||||
timeSpan: '11 h',
|
||||
generatedAt: '2026-07-10 22:18',
|
||||
recordNote: '基于示例数据生成的精简版日报',
|
||||
footerNote: '精简版默认面向长图转发;无内容模块自动隐藏。',
|
||||
heroParticipants: heroNames,
|
||||
avatars,
|
||||
reportMode: 'compact'
|
||||
},
|
||||
report: {
|
||||
hero: {
|
||||
headline: '接口排查和版本升级是今天主线',
|
||||
summary: '白天主要围绕接口异常、升级节奏和上线安排展开,结论比争论更多,待跟进事项也比较集中。',
|
||||
keyTakeaway: '大家确认本次异常更像缓存与配置问题,而不是服务端挂掉。',
|
||||
pendingNote: '测试环境接口文档和回滚方案仍需补齐。',
|
||||
statusLine: '今日形成 3 个结论 · 2 个待办 · 1 个问题尚未解决'
|
||||
},
|
||||
summaryStats: {
|
||||
messageCount: 382,
|
||||
activeUsers: 47,
|
||||
topicCount: 4,
|
||||
mediaCount: 28,
|
||||
imageCount: 9,
|
||||
voiceCount: 5,
|
||||
stickerCount: 14,
|
||||
conclusionCount: 3,
|
||||
todoCount: 2,
|
||||
unresolvedCount: 1
|
||||
},
|
||||
sectionMeta: {
|
||||
hero: { enabled: true, importance: 1, confidence: 0.95, totalCount: 1, displayedCount: 1 },
|
||||
topics: { enabled: true, importance: 0.98, confidence: 0.86, totalCount: 6, displayedCount: 3, hiddenCount: 3 },
|
||||
importantMessages: { enabled: true, importance: 0.95, confidence: 0.84, totalCount: 6, displayedCount: 3, hiddenCount: 3 },
|
||||
actions: { enabled: true, importance: 0.97, confidence: 0.81, totalCount: 5, displayedCount: 3, hiddenCount: 2 },
|
||||
moments: { enabled: true, importance: 0.75, confidence: 0.75, totalCount: 3, displayedCount: 1, hiddenCount: 2 },
|
||||
analytics: { enabled: true, importance: 0.8, confidence: 0.95, totalCount: 1, displayedCount: 1 },
|
||||
keywords: { enabled: true, importance: 0.68, confidence: 0.92, totalCount: 16, displayedCount: 12, hiddenCount: 4 }
|
||||
},
|
||||
topics: [
|
||||
{
|
||||
title: 'GPT 接口异常排查',
|
||||
timeRange: '09:20-11:05',
|
||||
heat: '高',
|
||||
summary: '上午先从接口超时和返回结构异常入手,几轮排查后,大家逐步把问题收敛到缓存与环境配置,而不是后端服务不可用。',
|
||||
conclusions: [
|
||||
{ text: '接口本身可用,异常更像本地缓存与环境变量冲突。' },
|
||||
{ text: '先清缓存再复测,避免把旧响应误判成线上事故。' }
|
||||
],
|
||||
participants: ['阿宇', '老周', '小李'],
|
||||
keywords: ['接口', '缓存', '环境变量'],
|
||||
image: {
|
||||
imageUrl: sampleImage,
|
||||
note: '该图片引发 12 条回复。根据图片前后对话推断,这是一张帮助定位问题的截图。'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '版本升级节奏',
|
||||
timeRange: '11:40-12:20',
|
||||
heat: '中',
|
||||
summary: '关于 React 版本是否立刻升级,讨论从“能不能升”转成“这周值不值得升”,最终倾向先补兼容性清单再动。',
|
||||
conclusions: [{ text: '先列旧组件兼容清单,再决定升级窗口。' }],
|
||||
participants: ['阿宇', 'we water', '佩佩'],
|
||||
keywords: ['React', '升级', '兼容性']
|
||||
},
|
||||
{
|
||||
title: '上线节奏与回滚准备',
|
||||
timeRange: '15:10-16:05',
|
||||
heat: '中',
|
||||
summary: '下午讨论上线方案时,大家更关注回滚准备是否充分,最后把重点放在文档、监控和回滚路径补齐上。',
|
||||
conclusions: [{ text: '上线前需要补一版简短回滚说明。' }],
|
||||
participants: ['老周', '小李'],
|
||||
keywords: ['上线', '回滚', '监控']
|
||||
},
|
||||
],
|
||||
importantMessages: [
|
||||
{ sender: '阿宇', time: '10:41', content: '先别回滚,接口能通。', note: '稳定了排查方向。' },
|
||||
{ sender: '老周', time: '11:02', content: '像是缓存没清掉。', note: '把问题从服务端收敛到本地环境。' },
|
||||
{ sender: '小李', time: '15:36', content: '上线前把回滚文档补一下。', note: '明确形成待办。' }
|
||||
],
|
||||
todos: [
|
||||
{ task: '补测试环境接口文档', owner: '小李', topic: 'GPT 接口异常排查', note: '方便明天复测。' },
|
||||
{ task: '整理回滚说明', owner: '老周', deadline: '今晚', topic: '上线节奏与回滚准备' }
|
||||
],
|
||||
unresolved: [
|
||||
{ question: '缓存问题的根因是不是插件残留?', owner: '阿宇', status: '待跟进', lastDiscussedAt: '18:26', note: '目前只有推断,还没有最终证据。' }
|
||||
],
|
||||
quotes: [
|
||||
{
|
||||
messages: [
|
||||
{ sender: '阿宇', content: '我以为接口炸了。' },
|
||||
{ sender: '老周', content: '先别慌,先清缓存。' },
|
||||
{ sender: '小李', content: '清完它居然真好了。' }
|
||||
],
|
||||
note: '从“要不要回滚”迅速切到“先做最小验证”,很像今天的群聊节奏。'
|
||||
}
|
||||
],
|
||||
analytics: {
|
||||
topSpeakers: [
|
||||
{ name: '阿宇', count: 112 },
|
||||
{ name: '老周', count: 78 },
|
||||
{ name: '小李', count: 61 },
|
||||
{ name: 'we water', count: 49 },
|
||||
{ name: '佩佩', count: 33 }
|
||||
],
|
||||
activeTimeline: '09:00-09:59、10:00-10:59、15:00-15:59',
|
||||
voiceLeaderboard: []
|
||||
},
|
||||
keywords: ['GPT', '接口', '缓存', '升级', '上线', '回滚', '监控', '兼容性', '截图', '文档', '测试环境', '复测'],
|
||||
media: {
|
||||
gallery: [],
|
||||
voiceHighlights: [],
|
||||
funBadges: []
|
||||
},
|
||||
resources: [],
|
||||
qa: [],
|
||||
storylines: [],
|
||||
reversals: [],
|
||||
participantChains: []
|
||||
}
|
||||
}
|
||||
|
||||
const fullRequest = JSON.parse(JSON.stringify(compactRequest))
|
||||
fullRequest.metadata.recordNote = '基于示例数据生成的完整版日报'
|
||||
fullRequest.metadata.reportMode = 'full'
|
||||
fullRequest.report.sectionMeta = {
|
||||
...fullRequest.report.sectionMeta,
|
||||
resources: { enabled: true, importance: 0.58, confidence: 0.76, totalCount: 2, displayedCount: 2 },
|
||||
qa: { enabled: true, importance: 0.62, confidence: 0.8, totalCount: 2, displayedCount: 2 },
|
||||
storylines: { enabled: true, importance: 0.68, confidence: 0.74, totalCount: 2, displayedCount: 2 },
|
||||
reversals: { enabled: true, importance: 0.55, confidence: 0.72, totalCount: 1, displayedCount: 1 },
|
||||
gallery: { enabled: true, importance: 0.64, confidence: 0.82, totalCount: 2, displayedCount: 2 },
|
||||
voices: { enabled: true, importance: 0.6, confidence: 0.83, totalCount: 2, displayedCount: 2 },
|
||||
badges: { enabled: true, importance: 0.45, confidence: 0.68, totalCount: 2, displayedCount: 2 },
|
||||
chains: { enabled: true, importance: 0.58, confidence: 0.72, totalCount: 1, displayedCount: 1 }
|
||||
}
|
||||
fullRequest.report.resources = [
|
||||
{ title: '测试环境接口文档', description: '明天复测会直接用到的说明。', sender: '小李' },
|
||||
{ title: '回滚说明草稿', description: '上线前确认回滚路径与负责人。', sender: '老周' }
|
||||
]
|
||||
fullRequest.report.qa = [
|
||||
{ question: '今晚要不要升级 React?', answer: '先不升级,先补兼容清单。', answerer: 'we water' },
|
||||
{ question: '接口是不是服务端挂了?', answer: '不是,当前更像缓存与环境问题。', answerer: '老周' }
|
||||
]
|
||||
fullRequest.report.storylines = [
|
||||
{
|
||||
title: '接口异常排查线',
|
||||
stages: [
|
||||
{ time: '09:20', event: '阿宇提出接口异常。' },
|
||||
{ time: '09:46', event: '老周建议先清缓存。' },
|
||||
{ time: '10:41', event: '确认接口本身可通。' }
|
||||
],
|
||||
result: '初步定位为缓存与配置冲突。'
|
||||
},
|
||||
{
|
||||
title: '版本升级讨论线',
|
||||
stages: [
|
||||
{ time: '11:40', event: '开始讨论是否本周升级。' },
|
||||
{ time: '12:05', event: '补充兼容性与工期顾虑。' }
|
||||
],
|
||||
result: '今晚不升,先补兼容清单。'
|
||||
}
|
||||
]
|
||||
fullRequest.report.reversals = [
|
||||
{ topic: '接口异常', initialView: '最初以为后端服务不稳定。', finalView: '最终判断更像缓存与配置问题。', note: '多轮验证后,排查方向明显收敛。' }
|
||||
]
|
||||
fullRequest.report.media = {
|
||||
gallery: [
|
||||
{
|
||||
sender: '阿宇',
|
||||
time: '09:52',
|
||||
imageUrl: sampleImage,
|
||||
note: '图片发出后,群里立刻围绕异常现象、返回结构和复现环境展开讨论。',
|
||||
stats: '12 条后续消息 · 6 人接话',
|
||||
inferenceLabel: '基于图片后的聊天上下文推断'
|
||||
},
|
||||
{
|
||||
sender: '佩佩',
|
||||
time: '17:14',
|
||||
imageUrl: sampleImage,
|
||||
note: '第二张图带起一轮轻松但有效的快速确认。',
|
||||
stats: '5 条后续消息 · 3 人接话',
|
||||
inferenceLabel: '基于图片后的聊天上下文推断'
|
||||
}
|
||||
],
|
||||
voiceHighlights: [
|
||||
{ title: '语音输出王', sender: '老周', note: '共发送 3 条语音,累计 97 秒。' },
|
||||
{ title: '连续发言时刻', sender: '阿宇', note: '16:32 连发 2 条语音,共 54 秒。' }
|
||||
],
|
||||
funBadges: [
|
||||
{ title: '高能输出王', owner: '阿宇', note: '今天一共发了 112 条消息。' },
|
||||
{ title: '语音麦霸', owner: '老周', note: '语音总时长位列第一。' }
|
||||
]
|
||||
}
|
||||
fullRequest.report.participantChains = [
|
||||
{ topic: '接口异常排查', chain: ['阿宇 提出', '老周 收敛方向', '小李 验证', 'we water 定结论'], note: '比较典型的一条技术讨论链路。' }
|
||||
]
|
||||
fullRequest.report.analytics.voiceLeaderboard = [
|
||||
{ sender: '老周', count: 3, durationSec: 97 },
|
||||
{ sender: '阿宇', count: 2, durationSec: 54 }
|
||||
]
|
||||
|
||||
async function renderRequest(request, targetBase) {
|
||||
let html = await fs.readFile(templatePath, 'utf8')
|
||||
const report = request.report
|
||||
const metadata = request.metadata
|
||||
const topicCards = report.topics
|
||||
.map((topic) => {
|
||||
const conclusions = (topic.conclusions || [])
|
||||
.slice(0, 2)
|
||||
.map((entry) => `<div class="topic-conclusion">${escapeHtml(entry.text)}</div>`)
|
||||
.join('')
|
||||
const image = topic.image?.imageUrl
|
||||
? `<div class="topic-inline-image"><img src="${topic.image.imageUrl}" alt="热点图片"><div>${escapeHtml(topic.image.note)}</div></div>`
|
||||
: ''
|
||||
return `<div class="card topic-card"><div class="topic-title-row"><h3>${escapeHtml(topic.title)}</h3><span class="heat ${topic.heat === '高' ? 'hot' : topic.heat === '低' ? 'blue' : ''}">${escapeHtml(topic.heat)}热</span></div><div class="topic-meta">${escapeHtml(topic.timeRange)}</div><p>${escapeHtml(topic.summary)}</p>${conclusions ? `<div class="topic-conclusions">${conclusions}</div>` : ''}${image}<div class="participants">${topic.participants.map((name) => `<span class="person-chip"><img src="${avatars[name] || avatarSvg(name[0], '#e5e7eb')}" alt=""><b>${escapeHtml(name)}</b></span>`).join('')}</div><div class="keywords">${topic.keywords.map((word) => `<span>${escapeHtml(word)}</span>`).join('')}</div></div>`
|
||||
})
|
||||
.join('')
|
||||
const importantMessages = report.importantMessages
|
||||
.map((message) => `<div class="important-card"><img class="avatar" src="${avatars[message.sender] || avatarSvg(message.sender[0], '#e5e7eb')}" alt=""><div class="important-body"><div class="important-meta"><b>${escapeHtml(message.sender)}</b><span>${escapeHtml(message.time)}</span></div><div class="important-text">${escapeHtml(message.content)}</div><div class="important-note">${escapeHtml(message.note)}</div></div></div>`)
|
||||
.join('')
|
||||
const todoCards = (report.todos || []).map((item) => `<div class="action-card todo-card"><b>${escapeHtml(item.task)}</b><div>${[item.owner || '', item.deadline || '', item.topic || ''].filter(Boolean).map(escapeHtml).join(' · ')}</div>${item.note ? `<div class="action-note">${escapeHtml(item.note)}</div>` : ''}</div>`).join('')
|
||||
const unresolvedCards = (report.unresolved || []).map((item) => `<div class="action-card unresolved-card"><b>${escapeHtml(item.question)}</b><div>${[item.owner || '', item.lastDiscussedAt || '', item.status].filter(Boolean).map(escapeHtml).join(' · ')}</div><div class="action-note">${escapeHtml(item.note)}</div></div>`).join('')
|
||||
const quoteBlocks = report.quotes.map((quote) => `<div class="chat-block">${quote.messages.map((message) => `<div class="chat-msg"><img class="chat-avatar" src="${avatars[message.sender] || avatarSvg(message.sender[0], '#e5e7eb')}" alt=""><div><div class="chat-name">${escapeHtml(message.sender)}</div><div class="chat-bubble">${escapeHtml(message.content)}</div></div></div>`).join('')}<div class="quote-note">${escapeHtml(quote.note)}</div></div>`).join('')
|
||||
const rankItems = report.analytics.topSpeakers.map((speaker, index) => `<div class="rank"><img src="${avatars[speaker.name] || avatarSvg(speaker.name[0], '#e5e7eb')}" alt=""><b>${index + 1}. ${escapeHtml(speaker.name)}</b><span>${speaker.count} 条</span></div>`).join('')
|
||||
const cloudTags = report.keywords.map((word, index) => `<span class="${index < 2 ? 'xl' : index < 5 ? 'lg' : index < 9 ? 'md' : ''}">${escapeHtml(word)}</span>`).join('')
|
||||
const resourceItems = (report.resources || []).map((resource) => `<div class="resource"><b>${escapeHtml(resource.title)}</b>${resource.sender ? ` · ${escapeHtml(resource.sender)}` : ''}<br>${escapeHtml(resource.description)}</div>`).join('')
|
||||
const qaCards = (report.qa || []).map((item) => `<div class="qa-card"><b>Q:${escapeHtml(item.question)}</b><div>A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}</div></div>`).join('')
|
||||
const storylineCards = (report.storylines || []).map((item) => `<div class="card storyline-card"><div class="topic-title-row"><h3>${escapeHtml(item.title)}</h3></div><div class="storyline-steps">${item.stages.map((stage) => `<div class="storyline-step"><span>${escapeHtml(stage.time)}</span><b>${escapeHtml(stage.event)}</b></div>`).join('')}</div>${item.result ? `<p class="muted">${escapeHtml(item.result)}</p>` : ''}</div>`).join('')
|
||||
const reversalCards = (report.reversals || []).map((item) => `<div class="qa-card"><b>${escapeHtml(item.topic)}</b><div>最初:${escapeHtml(item.initialView)}</div><div>后来:${escapeHtml(item.finalView)}</div>${item.note ? `<div>${escapeHtml(item.note)}</div>` : ''}</div>`).join('')
|
||||
const galleryCards = (report.media.gallery || []).map((item) => `<div class="gallery-card"><img class="gallery-image" src="${item.imageUrl}" alt=""><div class="gallery-body"><div class="important-meta"><b>${escapeHtml(item.sender)}</b><span>${escapeHtml(item.time)}</span></div>${item.stats ? `<div class="gallery-stats">${escapeHtml(item.stats)}</div>` : ''}<div class="important-text">${escapeHtml(item.note)}</div>${item.inferenceLabel ? `<div class="topic-meta">${escapeHtml(item.inferenceLabel)}</div>` : ''}</div></div>`).join('')
|
||||
const voiceCards = (report.media.voiceHighlights || []).map((item) => `<div class="qa-card"><b>${escapeHtml(item.title)} · ${escapeHtml(item.sender)}</b><div>${escapeHtml(item.note)}</div></div>`).join('')
|
||||
const voiceRankCards = (report.analytics.voiceLeaderboard || []).map((item, index) => `<div class="rank"><img src="${avatars[item.sender] || avatarSvg(item.sender[0], '#e5e7eb')}" alt=""><b>${index + 1}. ${escapeHtml(item.sender)}</b><span>${item.count} 条 · ${item.durationSec} 秒</span></div>`).join('')
|
||||
const badgeCards = (report.media.funBadges || []).map((item) => `<div class="badge-card"><span class="tag">${escapeHtml(item.title)}</span><b>${escapeHtml(item.owner)}</b><p>${escapeHtml(item.note)}</p></div>`).join('')
|
||||
const chainCards = (report.participantChains || []).map((item) => `<div class="card chain-card"><div class="topic-title-row"><h3>${escapeHtml(item.topic)}</h3></div><div class="chain-flow">${item.chain.map((node) => `<span>${escapeHtml(node)}</span>`).join('<i>→</i>')}</div>${item.note ? `<p class="muted">${escapeHtml(item.note)}</p>` : ''}</div>`).join('')
|
||||
|
||||
const replaceMap = {
|
||||
REPORT_TITLE: `${metadata.groupName}日报`,
|
||||
REPORT_MODE_CLASS: metadata.reportMode === 'full' ? 'full' : 'compact',
|
||||
GROUP_NAME: metadata.groupName,
|
||||
DATE_RANGE: metadata.dateRange,
|
||||
RECORD_NOTE: metadata.recordNote,
|
||||
REPORT_MODE_LABEL: metadata.reportMode === 'full' ? '完整版' : '精简版',
|
||||
HERO_HEADLINE: report.hero.headline,
|
||||
HERO_SUMMARY: report.hero.summary,
|
||||
HERO_STATUS_LINE: report.hero.statusLine || '',
|
||||
HERO_STATUS_EMPTY_CLASS: report.hero.statusLine ? '' : 'empty-section',
|
||||
HERO_TAKEAWAY: report.hero.keyTakeaway || '',
|
||||
HERO_TAKEAWAY_EMPTY_CLASS: report.hero.keyTakeaway ? '' : 'empty-section',
|
||||
HERO_PENDING: report.hero.pendingNote || '',
|
||||
HERO_PENDING_EMPTY_CLASS: report.hero.pendingNote ? '' : 'empty-section',
|
||||
HERO_AVATARS: heroAvatars,
|
||||
MESSAGE_COUNT: String(report.summaryStats.messageCount),
|
||||
ACTIVE_USERS: String(report.summaryStats.activeUsers),
|
||||
TOPIC_COUNT: String(report.summaryStats.topicCount),
|
||||
MEDIA_COUNT: String(report.summaryStats.mediaCount),
|
||||
TOPICS_EMPTY_CLASS: report.sectionMeta.topics?.enabled ? '' : 'empty-section',
|
||||
TOPIC_CARDS: topicCards,
|
||||
TOPICS_MORE_NOTE: report.sectionMeta.topics?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.topics.hiddenCount} 条内容,请在完整版中查看</div>` : '',
|
||||
MESSAGES_EMPTY_CLASS: report.sectionMeta.importantMessages?.enabled ? '' : 'empty-section',
|
||||
IMPORTANT_MESSAGES: importantMessages,
|
||||
MESSAGES_MORE_NOTE: report.sectionMeta.importantMessages?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.importantMessages.hiddenCount} 条内容,请在完整版中查看</div>` : '',
|
||||
ACTIONS_EMPTY_CLASS: report.sectionMeta.actions?.enabled ? '' : 'empty-section',
|
||||
TODO_EMPTY_CLASS: report.todos.length ? '' : 'empty-section',
|
||||
TODO_CARDS: todoCards,
|
||||
UNRESOLVED_EMPTY_CLASS: report.unresolved.length ? '' : 'empty-section',
|
||||
UNRESOLVED_CARDS: unresolvedCards,
|
||||
ACTIONS_MORE_NOTE: report.sectionMeta.actions?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.actions.hiddenCount} 条内容,请在完整版中查看</div>` : '',
|
||||
QUOTES_EMPTY_CLASS: report.sectionMeta.moments?.enabled ? '' : 'empty-section',
|
||||
QUOTE_BLOCKS: quoteBlocks,
|
||||
QUOTES_MORE_NOTE: report.sectionMeta.moments?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.moments.hiddenCount} 条内容,请在完整版中查看</div>` : '',
|
||||
ANALYTICS_EMPTY_CLASS: '',
|
||||
RANK_ITEMS: rankItems,
|
||||
ACTIVITY_TIMELINE: report.analytics.activeTimeline,
|
||||
CONCLUSION_COUNT: String(report.summaryStats.conclusionCount),
|
||||
TODO_COUNT: String(report.summaryStats.todoCount),
|
||||
UNRESOLVED_COUNT: String(report.summaryStats.unresolvedCount),
|
||||
KEYWORDS_EMPTY_CLASS: report.sectionMeta.keywords?.enabled ? '' : 'empty-section',
|
||||
CLOUD_TAGS: cloudTags,
|
||||
KEYWORDS_MORE_NOTE: report.sectionMeta.keywords?.hiddenCount ? `<div class="section-more">另有 ${report.sectionMeta.keywords.hiddenCount} 个关键词,请在完整版中查看</div>` : '',
|
||||
RESOURCES_EMPTY_CLASS: report.sectionMeta.resources?.enabled ? '' : 'empty-section',
|
||||
RESOURCE_ITEMS: resourceItems,
|
||||
RESOURCES_MORE_NOTE: '',
|
||||
QA_EMPTY_CLASS: report.sectionMeta.qa?.enabled ? '' : 'empty-section',
|
||||
QA_CARDS: qaCards,
|
||||
QA_MORE_NOTE: '',
|
||||
STORYLINES_EMPTY_CLASS: report.sectionMeta.storylines?.enabled ? '' : 'empty-section',
|
||||
STORYLINE_CARDS: storylineCards,
|
||||
STORYLINES_MORE_NOTE: '',
|
||||
REVERSALS_EMPTY_CLASS: report.sectionMeta.reversals?.enabled ? '' : 'empty-section',
|
||||
REVERSAL_CARDS: reversalCards,
|
||||
REVERSALS_MORE_NOTE: '',
|
||||
GALLERY_EMPTY_CLASS: report.sectionMeta.gallery?.enabled ? '' : 'empty-section',
|
||||
GALLERY_CARDS: galleryCards,
|
||||
GALLERY_MORE_NOTE: '',
|
||||
VOICE_EMPTY_CLASS: report.sectionMeta.voices?.enabled ? '' : 'empty-section',
|
||||
VOICE_CARDS: voiceCards,
|
||||
VOICE_MORE_NOTE: '',
|
||||
VOICE_RANK_EMPTY_CLASS: report.sectionMeta.voices?.enabled ? '' : 'empty-section',
|
||||
VOICE_RANK_CARDS: voiceRankCards,
|
||||
BADGES_EMPTY_CLASS: report.sectionMeta.badges?.enabled ? '' : 'empty-section',
|
||||
BADGE_CARDS: badgeCards,
|
||||
BADGES_MORE_NOTE: '',
|
||||
CHAINS_EMPTY_CLASS: report.sectionMeta.chains?.enabled ? '' : 'empty-section',
|
||||
CHAIN_CARDS: chainCards,
|
||||
CHAINS_MORE_NOTE: '',
|
||||
GENERATED_AT: metadata.generatedAt,
|
||||
FOOTER_NOTE: metadata.footerNote
|
||||
}
|
||||
for (const [key, value] of Object.entries(replaceMap)) html = replacePlaceholder(html, key, value)
|
||||
const htmlPath = path.join(outputDir, `${targetBase}.html`)
|
||||
const pngPath = path.join(outputDir, `${targetBase}.png`)
|
||||
await fs.ensureDir(outputDir)
|
||||
await fs.writeFile(htmlPath, html, 'utf8')
|
||||
|
||||
const win = new BrowserWindow({
|
||||
show: false,
|
||||
width: 430,
|
||||
height: 800,
|
||||
frame: false,
|
||||
backgroundColor: '#f3f5f7',
|
||||
webPreferences: { sandbox: true }
|
||||
})
|
||||
await win.loadFile(htmlPath)
|
||||
await win.webContents.executeJavaScript(`Promise.all([
|
||||
document.fonts.ready,
|
||||
...Array.from(document.images).map((img) => img.complete ? Promise.resolve() : new Promise((resolve) => {
|
||||
img.addEventListener('load', resolve, { once: true });
|
||||
img.addEventListener('error', resolve, { once: true });
|
||||
}))
|
||||
])`)
|
||||
win.webContents.debugger.attach('1.3')
|
||||
const metrics = await win.webContents.debugger.sendCommand('Page.getLayoutMetrics')
|
||||
const width = Math.max(430, Math.ceil(metrics.cssContentSize.width))
|
||||
const height = Math.ceil(metrics.cssContentSize.height)
|
||||
const screenshot = await win.webContents.debugger.sendCommand('Page.captureScreenshot', {
|
||||
format: 'png',
|
||||
captureBeyondViewport: true,
|
||||
fromSurface: true,
|
||||
clip: { x: 0, y: 0, width, height, scale: 1 }
|
||||
})
|
||||
await fs.writeFile(pngPath, Buffer.from(screenshot.data, 'base64'))
|
||||
if (win.webContents.debugger.isAttached()) win.webContents.debugger.detach()
|
||||
win.destroy()
|
||||
return { htmlPath, pngPath, height }
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const mode = process.argv.includes('--full') ? 'full' : 'compact'
|
||||
const result =
|
||||
mode === 'full'
|
||||
? await renderRequest(fullRequest, 'full-fixture')
|
||||
: await renderRequest(compactRequest, 'compact-fixture')
|
||||
console.log(JSON.stringify({ mode, result, outputDir }, null, 2))
|
||||
app.quit()
|
||||
})
|
||||
@@ -138,6 +138,29 @@ const heatClass = (heat: ReportHeat): string => {
|
||||
const replacePlaceholder = (html: string, key: string, value: string): string =>
|
||||
html.replaceAll(`{{${key}}}`, value)
|
||||
|
||||
const modeLabel = (mode: GroupReportMetadata['reportMode']): string =>
|
||||
mode === 'full' ? '完整版' : '精简版'
|
||||
|
||||
const sectionMeta = (
|
||||
request: GroupReportExportRequest,
|
||||
key: keyof NonNullable<typeof request.report.sectionMeta>
|
||||
) => request.report.sectionMeta?.[key]
|
||||
|
||||
const sectionClass = (
|
||||
request: GroupReportExportRequest,
|
||||
key: keyof NonNullable<typeof request.report.sectionMeta>,
|
||||
hasContent: boolean
|
||||
): string => (sectionMeta(request, key)?.enabled && hasContent ? '' : 'empty-section')
|
||||
|
||||
const overflowNote = (
|
||||
request: GroupReportExportRequest,
|
||||
key: keyof NonNullable<typeof request.report.sectionMeta>
|
||||
): string => {
|
||||
const meta = sectionMeta(request, key)
|
||||
if (request.metadata.reportMode !== 'compact' || !meta?.hiddenCount) return ''
|
||||
return `<div class="section-more">另有 ${meta.hiddenCount} 条内容,请在完整版中查看</div>`
|
||||
}
|
||||
|
||||
const renderReportHtml = async (request: GroupReportExportRequest): Promise<string> => {
|
||||
const { report, metadata } = request
|
||||
const avatarNames = new Set<string>(metadata.heroParticipants)
|
||||
@@ -147,6 +170,9 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
quote.messages.forEach((message) => avatarNames.add(message.sender))
|
||||
)
|
||||
report.analytics.topSpeakers.forEach((speaker) => avatarNames.add(speaker.name))
|
||||
report.media?.gallery?.forEach((item) => avatarNames.add(item.sender))
|
||||
report.media?.voiceHighlights?.forEach((item) => avatarNames.add(item.sender))
|
||||
report.media?.funBadges?.forEach((item) => avatarNames.add(item.owner))
|
||||
|
||||
const avatars = new Map<string, string>()
|
||||
await Promise.all(
|
||||
@@ -168,7 +194,21 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
<div class="topic-title-row"><h3>${escapeHtml(topic.title)}</h3><span class="heat ${heatClass(topic.heat)}">${escapeHtml(topic.heat)}热</span></div>
|
||||
<div class="topic-meta">${escapeHtml(topic.timeRange)}</div>
|
||||
<p>${escapeHtml(topic.summary)}</p>
|
||||
${topic.conclusion ? `<p class="muted">${escapeHtml(topic.conclusion)}</p>` : ''}
|
||||
${
|
||||
topic.conclusions?.length
|
||||
? `<div class="topic-conclusions">${topic.conclusions
|
||||
.slice(0, 2)
|
||||
.map((entry) => `<div class="topic-conclusion">${escapeHtml(entry.text)}</div>`)
|
||||
.join('')}</div>`
|
||||
: topic.conclusion
|
||||
? `<div class="topic-conclusions"><div class="topic-conclusion">${escapeHtml(topic.conclusion)}</div></div>`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
topic.image?.imageUrl
|
||||
? `<div class="topic-inline-image"><img src="${topic.image.imageUrl}" alt="热点图片"><div>${escapeHtml(topic.image.note)}</div></div>`
|
||||
: ''
|
||||
}
|
||||
<div class="participants">${topic.participants
|
||||
.slice(0, 5)
|
||||
.map(
|
||||
@@ -213,18 +253,104 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
)
|
||||
.join('')
|
||||
|
||||
const qaCards = report.qa
|
||||
const todoCards = (report.todos || [])
|
||||
.map(
|
||||
(item) =>
|
||||
`<div class="qa-card"><b>Q:${escapeHtml(item.question)}</b><div>A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}</div></div>`
|
||||
(item) => `<div class="action-card todo-card">
|
||||
<b>${escapeHtml(item.task)}</b>
|
||||
<div>${[item.owner || '', item.deadline || '', item.topic || ''].filter(Boolean).map(escapeHtml).join(' · ')}</div>
|
||||
${item.note ? `<div class="action-note">${escapeHtml(item.note)}</div>` : ''}
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const maxHeat = Math.max(1, ...report.analytics.topicHeat.map((item) => item.score))
|
||||
const heatBars = report.analytics.topicHeat
|
||||
const unresolvedCards = (report.unresolved || [])
|
||||
.map(
|
||||
(item) =>
|
||||
`<div class="bar-row"><span>${escapeHtml(item.topic)}</span><div class="bar"><i style="width:${Math.max(8, Math.round((item.score / maxHeat) * 100))}%"></i></div></div>`
|
||||
(item) => `<div class="action-card unresolved-card">
|
||||
<b>${escapeHtml(item.question)}</b>
|
||||
<div>${[item.owner || '', item.lastDiscussedAt || '', item.status].filter(Boolean).map(escapeHtml).join(' · ')}</div>
|
||||
<div class="action-note">${escapeHtml(item.note)}</div>
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const storylineCards = (report.storylines || [])
|
||||
.map(
|
||||
(item) => `<div class="card storyline-card">
|
||||
<div class="topic-title-row"><h3>${escapeHtml(item.title)}</h3></div>
|
||||
<div class="storyline-steps">${item.stages
|
||||
.map(
|
||||
(stage) => `<div class="storyline-step">
|
||||
<span>${escapeHtml(stage.time || '--:--')}</span>
|
||||
<b>${escapeHtml(stage.event)}</b>
|
||||
</div>`
|
||||
)
|
||||
.join('')}</div>
|
||||
${item.result ? `<p class="muted">${escapeHtml(item.result)}</p>` : ''}
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const reversalCards = (report.reversals || [])
|
||||
.map(
|
||||
(item) => `<div class="qa-card">
|
||||
<b>${escapeHtml(item.topic)}</b>
|
||||
<div>最初:${escapeHtml(item.initialView)}</div>
|
||||
<div>后来:${escapeHtml(item.finalView)}</div>
|
||||
${item.note ? `<div>${escapeHtml(item.note)}</div>` : ''}
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const chainCards = (report.participantChains || [])
|
||||
.map(
|
||||
(item) => `<div class="card chain-card">
|
||||
<div class="topic-title-row"><h3>${escapeHtml(item.topic)}</h3></div>
|
||||
<div class="chain-flow">${item.chain.map((node) => `<span>${escapeHtml(node)}</span>`).join('<i>→</i>')}</div>
|
||||
${item.note ? `<p class="muted">${escapeHtml(item.note)}</p>` : ''}
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const galleryCards = (report.media?.gallery || [])
|
||||
.map(
|
||||
(item) => `<div class="gallery-card">
|
||||
<img class="gallery-image" src="${item.imageUrl}" alt="群聊图片">
|
||||
<div class="gallery-body">
|
||||
<div class="important-meta"><b>${escapeHtml(item.sender)}</b><span>${escapeHtml(item.time)}</span></div>
|
||||
${item.stats ? `<div class="gallery-stats">${escapeHtml(item.stats)}</div>` : ''}
|
||||
<div class="important-text">${escapeHtml(item.note)}</div>
|
||||
${item.inferenceLabel ? `<div class="topic-meta">${escapeHtml(item.inferenceLabel)}</div>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const voiceCards = (report.media?.voiceHighlights || [])
|
||||
.map(
|
||||
(item) => `<div class="qa-card">
|
||||
<b>${escapeHtml(item.title)} · ${escapeHtml(item.sender)}</b>
|
||||
<div>${escapeHtml(item.note)}</div>
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const voiceRankCards = (report.analytics.voiceLeaderboard || [])
|
||||
.map(
|
||||
(item, index) => `<div class="rank">
|
||||
<img src="${avatar(item.sender)}" alt="">
|
||||
<b>${index + 1}. ${escapeHtml(item.sender)}</b>
|
||||
<span>${item.count} 条 · ${item.durationSec} 秒</span>
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const badgeCards = (report.media?.funBadges || [])
|
||||
.map(
|
||||
(item) => `<div class="badge-card">
|
||||
<span class="tag">${escapeHtml(item.title)}</span>
|
||||
<b>${escapeHtml(item.owner)}</b>
|
||||
<p>${escapeHtml(item.note)}</p>
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
@@ -244,31 +370,98 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
)
|
||||
.join('')
|
||||
|
||||
const qaCards = report.qa
|
||||
.map(
|
||||
(item) =>
|
||||
`<div class="qa-card"><b>Q:${escapeHtml(item.question)}</b><div>A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}</div></div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const summaryStats = report.summaryStats || {
|
||||
messageCount: metadata.messageCount,
|
||||
activeUsers: metadata.activeUsers,
|
||||
topicCount: report.topics.length,
|
||||
mediaCount: metadata.mediaMessageCount || 0,
|
||||
imageCount: metadata.imageCount || 0,
|
||||
voiceCount: metadata.voiceCount || 0,
|
||||
stickerCount: metadata.stickerCount || 0,
|
||||
conclusionCount: 0,
|
||||
todoCount: report.todos.length,
|
||||
unresolvedCount: report.unresolved.length
|
||||
}
|
||||
|
||||
let html = await fs.readFile(templatePath(), 'utf8')
|
||||
const values: Record<string, string> = {
|
||||
REPORT_TITLE: escapeHtml(`${metadata.groupName}日报`),
|
||||
REPORT_MODE_CLASS: metadata.reportMode === 'full' ? 'full' : 'compact',
|
||||
GROUP_NAME: escapeHtml(metadata.groupName),
|
||||
DATE_RANGE: escapeHtml(metadata.dateRange),
|
||||
RECORD_NOTE: escapeHtml(`基于 WechatExplorer 加载的 ${metadata.messageCount} 条记录`),
|
||||
OVERVIEW: escapeHtml(report.overview),
|
||||
RECORD_NOTE: escapeHtml(metadata.recordNote),
|
||||
REPORT_MODE_LABEL: escapeHtml(modeLabel(metadata.reportMode)),
|
||||
HERO_HEADLINE: escapeHtml(report.hero?.headline || '今日群聊速览'),
|
||||
HERO_SUMMARY: escapeHtml(report.hero?.summary || report.overview),
|
||||
HERO_TAKEAWAY: escapeHtml(report.hero?.keyTakeaway || ''),
|
||||
HERO_PENDING: escapeHtml(report.hero?.pendingNote || ''),
|
||||
HERO_STATUS_LINE: escapeHtml(report.hero?.statusLine || ''),
|
||||
HERO_STATUS_EMPTY_CLASS: report.hero?.statusLine ? '' : 'empty-section',
|
||||
HERO_TAKEAWAY_EMPTY_CLASS: report.hero?.keyTakeaway ? '' : 'empty-section',
|
||||
HERO_PENDING_EMPTY_CLASS: report.hero?.pendingNote ? '' : 'empty-section',
|
||||
HERO_AVATARS: heroAvatars,
|
||||
MESSAGE_COUNT: String(metadata.messageCount),
|
||||
ACTIVE_USERS: String(metadata.activeUsers),
|
||||
MESSAGE_COUNT: String(summaryStats.messageCount),
|
||||
ACTIVE_USERS: String(summaryStats.activeUsers),
|
||||
TIME_SPAN: escapeHtml(metadata.timeSpan || ''),
|
||||
TOPIC_COUNT: String(report.topics.length),
|
||||
TOPIC_COUNT: String(summaryStats.topicCount),
|
||||
MEDIA_COUNT: String(summaryStats.mediaCount),
|
||||
TOPIC_CARDS: topicCards,
|
||||
RESOURCES_EMPTY_CLASS: report.resources.length ? '' : 'empty-section',
|
||||
TOPICS_EMPTY_CLASS: sectionClass(request, 'topics', report.topics.length > 0),
|
||||
TOPICS_MORE_NOTE: overflowNote(request, 'topics'),
|
||||
RESOURCES_EMPTY_CLASS: sectionClass(request, 'resources', report.resources.length > 0),
|
||||
RESOURCE_ITEMS: resourceItems,
|
||||
MESSAGES_EMPTY_CLASS: report.importantMessages.length ? '' : 'empty-section',
|
||||
RESOURCES_MORE_NOTE: overflowNote(request, 'resources'),
|
||||
MESSAGES_EMPTY_CLASS: sectionClass(request, 'importantMessages', report.importantMessages.length > 0),
|
||||
IMPORTANT_MESSAGES: importantMessages,
|
||||
QUOTES_EMPTY_CLASS: report.quotes.length ? '' : 'empty-section',
|
||||
MESSAGES_MORE_NOTE: overflowNote(request, 'importantMessages'),
|
||||
QUOTES_EMPTY_CLASS: sectionClass(request, 'moments', report.quotes.length > 0),
|
||||
QUOTE_BLOCKS: quoteBlocks,
|
||||
QA_EMPTY_CLASS: report.qa.length ? '' : 'empty-section',
|
||||
QUOTES_MORE_NOTE: overflowNote(request, 'moments'),
|
||||
ACTIONS_EMPTY_CLASS: sectionClass(request, 'actions', report.todos.length + report.unresolved.length > 0),
|
||||
TODO_EMPTY_CLASS: report.todos.length ? '' : 'empty-section',
|
||||
TODO_CARDS: todoCards,
|
||||
UNRESOLVED_EMPTY_CLASS: report.unresolved?.length ? '' : 'empty-section',
|
||||
UNRESOLVED_CARDS: unresolvedCards,
|
||||
ACTIONS_MORE_NOTE: overflowNote(request, 'actions'),
|
||||
QA_EMPTY_CLASS: sectionClass(request, 'qa', report.qa.length > 0),
|
||||
QA_CARDS: qaCards,
|
||||
HEAT_BARS: heatBars,
|
||||
QA_MORE_NOTE: overflowNote(request, 'qa'),
|
||||
STORYLINES_EMPTY_CLASS: sectionClass(request, 'storylines', report.storylines?.length > 0),
|
||||
STORYLINE_CARDS: storylineCards,
|
||||
STORYLINES_MORE_NOTE: overflowNote(request, 'storylines'),
|
||||
REVERSALS_EMPTY_CLASS: sectionClass(request, 'reversals', report.reversals?.length > 0),
|
||||
REVERSAL_CARDS: reversalCards,
|
||||
REVERSALS_MORE_NOTE: overflowNote(request, 'reversals'),
|
||||
CHAINS_EMPTY_CLASS: sectionClass(request, 'chains', report.participantChains?.length > 0),
|
||||
CHAIN_CARDS: chainCards,
|
||||
CHAINS_MORE_NOTE: overflowNote(request, 'chains'),
|
||||
GALLERY_EMPTY_CLASS: sectionClass(request, 'gallery', report.media?.gallery?.length > 0),
|
||||
GALLERY_CARDS: galleryCards,
|
||||
GALLERY_MORE_NOTE: overflowNote(request, 'gallery'),
|
||||
VOICE_EMPTY_CLASS: sectionClass(request, 'voices', report.media?.voiceHighlights?.length > 0),
|
||||
VOICE_CARDS: voiceCards,
|
||||
VOICE_MORE_NOTE: overflowNote(request, 'voices'),
|
||||
VOICE_RANK_EMPTY_CLASS: sectionClass(request, 'voices', report.analytics.voiceLeaderboard?.length > 0),
|
||||
VOICE_RANK_CARDS: voiceRankCards,
|
||||
BADGES_EMPTY_CLASS: sectionClass(request, 'badges', report.media?.funBadges?.length > 0),
|
||||
BADGE_CARDS: badgeCards,
|
||||
BADGES_MORE_NOTE: overflowNote(request, 'badges'),
|
||||
RANK_ITEMS: rankItems,
|
||||
ACTIVITY_TIMELINE: escapeHtml(report.analytics.activeTimeline),
|
||||
CONCLUSION_COUNT: String(summaryStats.conclusionCount),
|
||||
TODO_COUNT: String(summaryStats.todoCount),
|
||||
UNRESOLVED_COUNT: String(summaryStats.unresolvedCount),
|
||||
CLOUD_TAGS: cloudTags,
|
||||
KEYWORDS_EMPTY_CLASS: sectionClass(request, 'keywords', report.keywords.length > 0),
|
||||
KEYWORDS_MORE_NOTE: overflowNote(request, 'keywords'),
|
||||
ANALYTICS_EMPTY_CLASS: sectionClass(request, 'analytics', true),
|
||||
GENERATED_AT: escapeHtml(metadata.generatedAt),
|
||||
FOOTER_NOTE: escapeHtml(metadata.footerNote)
|
||||
}
|
||||
@@ -327,7 +520,7 @@ export const exportGroupReport = async (
|
||||
|
||||
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
|
||||
await fs.ensureDir(outputDir)
|
||||
const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_可视化长图`
|
||||
const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_${request.metadata.reportMode === 'full' ? '完整版' : '精简版'}`
|
||||
const htmlPath = path.join(outputDir, `${baseName}.html`)
|
||||
const pngPath = path.join(outputDir, `${baseName}.png`)
|
||||
const htmlStartedAt = new Date()
|
||||
|
||||
@@ -1,111 +1,124 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Message, Contact } from '../../../shared/types'
|
||||
import { ChatHeader } from './chat/ChatHeader'
|
||||
import { ChatStatusBar } from './chat/ChatStatusBar'
|
||||
import { DataTrustBar } from './chat/DataTrustBar'
|
||||
import { EmptyConversationState } from './chat/EmptyConversationState'
|
||||
import { ExportRange } from './chat/ExportMenu'
|
||||
import { MessageList } from './chat/MessageList'
|
||||
import { VoicePlayer } from './VoicePlayer'
|
||||
import { RichMessageBubble } from './RichMessageBubble'
|
||||
import { ImageBubble } from './ImageBubble'
|
||||
import {
|
||||
buildGroupReportInput,
|
||||
GROUP_REPORT_SYSTEM_PROMPT,
|
||||
parseGroupDailyReport
|
||||
} from '../utils/group-report'
|
||||
import type { ReportMode } from '../../../shared/group-report'
|
||||
|
||||
interface ChatWindowProps {
|
||||
contact: Contact | null
|
||||
messages: Message[]
|
||||
isLoadingMessages?: boolean
|
||||
contentFilter?: string
|
||||
dateRange?: string
|
||||
onContentFilterChange?: (keyword: string) => void
|
||||
onRefresh?: () => void
|
||||
onRefreshData?: () => void
|
||||
onCreateGroupReport?: () => void
|
||||
isAiLoading?: boolean
|
||||
}
|
||||
|
||||
const MAX_RENDERED_MESSAGES = 600
|
||||
const DATE_RANGE_LABELS: Record<string, string> = {
|
||||
today: '今天',
|
||||
yesterday: '昨日',
|
||||
'7': '7 天',
|
||||
'30': '30 天',
|
||||
all: '全部'
|
||||
}
|
||||
type SummaryDateRange = 'today' | 'yesterday' | '7days'
|
||||
type SummaryMessageType = 'text' | 'image' | 'sticker' | 'video' | 'voice' | 'share' | 'system'
|
||||
|
||||
const formatClock = (date: Date): string =>
|
||||
`${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
const SUMMARY_DATE_OPTIONS: { value: SummaryDateRange; label: string }[] = [
|
||||
{ value: 'today', label: '今天' },
|
||||
{ value: 'yesterday', label: '昨日' },
|
||||
{ value: '7days', label: '最近 7 天' }
|
||||
]
|
||||
|
||||
const formatRangeDate = (date: Date, now: Date): string => {
|
||||
const clock = formatClock(date)
|
||||
if (date.getFullYear() === now.getFullYear()) {
|
||||
return `${date.getMonth() + 1} 月 ${date.getDate()} 日 ${clock}`
|
||||
}
|
||||
return `${date.getFullYear()} 年 ${date.getMonth() + 1} 月 ${date.getDate()} 日 ${clock}`
|
||||
}
|
||||
const SUMMARY_TYPE_OPTIONS: {
|
||||
value: SummaryMessageType
|
||||
label: string
|
||||
messageTypes: string[]
|
||||
}[] = [
|
||||
{ value: 'text', label: '文本', messageTypes: ['普通文本'] },
|
||||
{ value: 'image', label: '图片', messageTypes: ['图片'] },
|
||||
{ value: 'sticker', label: '表情包', messageTypes: ['表情包'] },
|
||||
{ value: 'video', label: '视频', messageTypes: ['视频'] },
|
||||
{ value: 'voice', label: '语音', messageTypes: ['语音'] },
|
||||
{ value: 'share', label: '分享/引用', messageTypes: ['分享消息', '名片', '位置', '通话'] },
|
||||
{ value: 'system', label: '系统消息', messageTypes: ['系统消息'] }
|
||||
]
|
||||
|
||||
const getChatHeaderRangeLabel = (range: string): string => {
|
||||
const getSummaryDateRange = (range: SummaryDateRange): { startTime: number; endTime: number } => {
|
||||
const now = new Date()
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const endOfYesterday = new Date(startOfToday.getTime() - 60_000)
|
||||
|
||||
if (range === 'today') return `今天 00:00—现在`
|
||||
if (range === 'yesterday') return `昨天 00:00—${formatClock(endOfYesterday)}`
|
||||
if (range === '7') {
|
||||
const start = new Date(Date.now() - 7 * 86400000)
|
||||
return `${formatRangeDate(start, now)}—现在`
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000
|
||||
const endTime = Math.floor(Date.now() / 1000)
|
||||
if (range === 'yesterday') {
|
||||
return { startTime: startOfToday - 86400, endTime: startOfToday - 1 }
|
||||
}
|
||||
if (range === '30') {
|
||||
const start = new Date(Date.now() - 30 * 86400000)
|
||||
return `${formatRangeDate(start, now)}—现在`
|
||||
if (range === '7days') {
|
||||
return { startTime: startOfToday - 6 * 86400, endTime }
|
||||
}
|
||||
if (range === 'all') return '全部记录'
|
||||
return DATE_RANGE_LABELS[range] || '当前范围'
|
||||
return { startTime: startOfToday, endTime }
|
||||
}
|
||||
|
||||
const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
contact,
|
||||
messages,
|
||||
isLoadingMessages,
|
||||
contentFilter,
|
||||
dateRange = 'today',
|
||||
onContentFilterChange,
|
||||
onRefresh,
|
||||
onRefreshData,
|
||||
onCreateGroupReport,
|
||||
isAiLoading = false
|
||||
onRefreshData
|
||||
}) => {
|
||||
const isGroupChat = Boolean(
|
||||
contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom')
|
||||
)
|
||||
const messageListRef = useRef<HTMLDivElement>(null)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
|
||||
const [reportPaths, setReportPaths] = useState<{ htmlPath: string; pngPath: string } | null>(null)
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null)
|
||||
const [imageScale, setImageScale] = useState(0.75)
|
||||
const [imageRotation, setImageRotation] = useState(0)
|
||||
const [imageOffset, setImageOffset] = useState({ x: 0, y: 0 })
|
||||
const imageViewerStageRef = useRef<HTMLDivElement>(null)
|
||||
const imageDragRef = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>(
|
||||
null
|
||||
)
|
||||
const [showAvatar, setShowAvatar] = useState(true)
|
||||
const [isAtLatest, setIsAtLatest] = useState(true)
|
||||
|
||||
const scrollToBottom = useCallback((): void => {
|
||||
// AI Settings
|
||||
const [showSettingsModal, setShowSettingsModal] = useState(false)
|
||||
const [apiKey, setApiKey] = useState(() => localStorage.getItem('ai_api_key') || '')
|
||||
const [baseURL, setBaseURL] = useState(
|
||||
() => localStorage.getItem('ai_base_url') || 'https://api.deepseek.com'
|
||||
)
|
||||
const [model, setModel] = useState(() => localStorage.getItem('ai_model') || 'deepseek-v4-flash')
|
||||
const [summaryDateRange, setSummaryDateRange] = useState<SummaryDateRange>('today')
|
||||
const [summaryMessageTypes, setSummaryMessageTypes] = useState<SummaryMessageType[]>(['text'])
|
||||
const [reportMode, setReportMode] = useState<ReportMode>(
|
||||
() => (localStorage.getItem('group_report_mode') as ReportMode) || 'compact'
|
||||
)
|
||||
|
||||
const handleSaveSettings = (): void => {
|
||||
if (!summaryMessageTypes.length) {
|
||||
alert('请至少选择一种消息类型')
|
||||
return
|
||||
}
|
||||
localStorage.setItem('ai_api_key', apiKey)
|
||||
localStorage.setItem('ai_base_url', baseURL)
|
||||
localStorage.setItem('ai_model', model)
|
||||
localStorage.setItem('group_report_mode', reportMode)
|
||||
setShowSettingsModal(false)
|
||||
AIChat()
|
||||
}
|
||||
|
||||
const toggleSummaryMessageType = (type: SummaryMessageType): void => {
|
||||
setSummaryMessageTypes((current) =>
|
||||
current.includes(type) ? current.filter((item) => item !== type) : [...current, type]
|
||||
)
|
||||
}
|
||||
|
||||
const scrollToBottom = (): void => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
|
||||
setIsAtLatest(true)
|
||||
}, [])
|
||||
|
||||
const handleMessageListScroll = useCallback((event: React.UIEvent<HTMLDivElement>): void => {
|
||||
const target = event.currentTarget
|
||||
const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight
|
||||
setIsAtLatest(distanceToBottom <= 24)
|
||||
}, [])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const frame = window.requestAnimationFrame(() => scrollToBottom())
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
}, [messages, scrollToBottom])
|
||||
scrollToBottom()
|
||||
}, [messages])
|
||||
|
||||
const openImagePreview = (imageUrl: string): void => {
|
||||
setPreviewImage(imageUrl)
|
||||
setImageScale(1)
|
||||
setImageScale(0.75)
|
||||
setImageRotation(0)
|
||||
setImageOffset({ x: 0, y: 0 })
|
||||
}
|
||||
@@ -116,18 +129,17 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
}
|
||||
|
||||
const zoomImage = (delta: number): void => {
|
||||
setImageScale((prev) => Math.min(8, Math.max(0.1, Number((prev + delta).toFixed(2)))))
|
||||
setImageScale((prev) => Math.min(3, Math.max(0.25, Number((prev + delta).toFixed(2)))))
|
||||
}
|
||||
|
||||
const resetImageTransform = (): void => {
|
||||
setImageScale(1)
|
||||
setImageScale(0.75)
|
||||
setImageRotation(0)
|
||||
setImageOffset({ x: 0, y: 0 })
|
||||
}
|
||||
|
||||
const handleViewerWheel = (event: React.WheelEvent): void => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
zoomImage(event.deltaY > 0 ? -0.1 : 0.1)
|
||||
}
|
||||
|
||||
@@ -154,25 +166,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
imageDragRef.current = null
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewImage) return
|
||||
|
||||
const previousOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
|
||||
const stage = imageViewerStageRef.current
|
||||
const preventBackgroundWheel = (event: WheelEvent): void => {
|
||||
event.preventDefault()
|
||||
}
|
||||
stage?.addEventListener('wheel', preventBackgroundWheel, { passive: false })
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow
|
||||
stage?.removeEventListener('wheel', preventBackgroundWheel)
|
||||
}
|
||||
}, [previewImage])
|
||||
|
||||
const handleExport = (days: ExportRange): void => {
|
||||
const handleExport = (days: number | 'all'): void => {
|
||||
if (!messages.length) return
|
||||
|
||||
let filtered = messages
|
||||
@@ -231,6 +225,67 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const AIChat = async (): Promise<void> => {
|
||||
if (!contact) return
|
||||
if (!summaryMessageTypes.length) {
|
||||
alert('请至少选择一种消息类型')
|
||||
return
|
||||
}
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const { startTime, endTime } = getSummaryDateRange(summaryDateRange)
|
||||
const rangeMessages = await window.api.getMessages(contact.md5, startTime, endTime)
|
||||
const allowedTypes = new Set(
|
||||
SUMMARY_TYPE_OPTIONS.filter((option) => summaryMessageTypes.includes(option.value)).flatMap(
|
||||
(option) => option.messageTypes
|
||||
)
|
||||
)
|
||||
const reportMessages = rangeMessages.filter((message) => allowedTypes.has(message.type))
|
||||
if (!reportMessages.length) throw new Error('当前条件下没有可总结的消息')
|
||||
|
||||
const input = await buildGroupReportInput(reportMessages, contact, isGroupChat, reportMode)
|
||||
console.log('🚀 ~ AIChat ~ input:', input)
|
||||
console.log('🚀 ~ AIChat ~ input.prompt:', input.prompt)
|
||||
const result = await window.api.aiChat(
|
||||
[
|
||||
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: input.prompt }
|
||||
],
|
||||
{ apiKey, model, baseURL }
|
||||
)
|
||||
|
||||
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
|
||||
const report = parseGroupDailyReport(
|
||||
result.data,
|
||||
input.topSpeakers,
|
||||
input.activeTimeline,
|
||||
input.voiceLeaderboard,
|
||||
input.metadata,
|
||||
input.media
|
||||
)
|
||||
const exported = await window.api.exportGroupReport({ report, metadata: input.metadata })
|
||||
if (!exported.success || !exported.imageDataUrl || !exported.htmlPath || !exported.pngPath) {
|
||||
throw new Error(exported.error || '日报文件生成失败')
|
||||
}
|
||||
setGeneratedImage(exported.imageDataUrl)
|
||||
setReportPaths({ htmlPath: exported.htmlPath, pngPath: exported.pngPath })
|
||||
} catch (error) {
|
||||
console.error('AI Call Failed:', error)
|
||||
alert(`AI 日报生成失败:${error instanceof Error ? error.message : String(error)}`)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
const handleCopyImage = async (): Promise<void> => {
|
||||
if (!generatedImage) return
|
||||
const result = await window.api.copyImage(generatedImage)
|
||||
if (result.success) {
|
||||
alert('复制成功')
|
||||
}
|
||||
}
|
||||
|
||||
const filteredMessages = React.useMemo(() => {
|
||||
return messages.filter((msg) => {
|
||||
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '')
|
||||
@@ -242,53 +297,194 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
return typeMatch && contentMatch
|
||||
})
|
||||
}, [messages, contentFilter])
|
||||
const hiddenMessageCount = Math.max(0, filteredMessages.length - MAX_RENDERED_MESSAGES)
|
||||
const renderedMessages = React.useMemo(
|
||||
() => filteredMessages.slice(-MAX_RENDERED_MESSAGES),
|
||||
[filteredMessages]
|
||||
|
||||
if (!contact) {
|
||||
return (
|
||||
<div className="chat-window">
|
||||
<div className="empty-state">选择一条消息</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!contact) return <EmptyConversationState />
|
||||
|
||||
const dateRangeLabel = getChatHeaderRangeLabel(dateRange)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-window">
|
||||
<ChatHeader
|
||||
contact={contact}
|
||||
isGroupChat={isGroupChat}
|
||||
dateRangeLabel={dateRangeLabel}
|
||||
loadedCount={messages.length}
|
||||
filteredCount={filteredMessages.length}
|
||||
contentFilter={contentFilter || ''}
|
||||
isAiLoading={isAiLoading}
|
||||
canExport={messages.length > 0}
|
||||
onContentFilterChange={onContentFilterChange || (() => undefined)}
|
||||
onRefresh={onRefresh}
|
||||
onRefreshData={onRefreshData}
|
||||
onExport={handleExport}
|
||||
onOpenAiSettings={onCreateGroupReport || (() => undefined)}
|
||||
<div className="chat-header">
|
||||
<h2>{contact.m_nsNickName}</h2>
|
||||
<div className="window-controls"></div>
|
||||
</div>
|
||||
|
||||
<div className="message-list wechat-message-list">
|
||||
{filteredMessages.map((msg) => {
|
||||
const isMine = msg.from === 'assistant'
|
||||
const isSystem = msg.from === 'system' || msg.type === '系统消息'
|
||||
const displayName = isMine
|
||||
? '我'
|
||||
: isGroupChat
|
||||
? msg.name || msg.from
|
||||
: contact.m_nsNickName
|
||||
const avatarSrc = isMine ? msg.img : msg.img || contact.avatar
|
||||
const isVoice = msg.type === '语音'
|
||||
const isImage = msg.type === '图片'
|
||||
const isRichMedia = ['名片', '位置', '分享消息', '通话', '表情包', '系统消息'].includes(
|
||||
msg.type
|
||||
)
|
||||
|
||||
if (isSystem) {
|
||||
return (
|
||||
<div key={msg.id} className="wechat-system-message-row">
|
||||
<div className="wechat-system-message">{msg.content}</div>
|
||||
<div className="wechat-system-message-meta">{msg.datetime}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={msg.id} className={`wechat-message-row ${isMine ? 'mine' : 'other'}`}>
|
||||
{!isMine && showAvatar && (
|
||||
<div className="message-avatar">
|
||||
{avatarSrc ? (
|
||||
<img src={avatarSrc} alt={displayName} referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
(displayName || '?').charAt(0)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="message-stack">
|
||||
{!isMine && isGroupChat && <div className="message-sender-name">{displayName}</div>}
|
||||
<div
|
||||
className={`message-bubble ${isVoice ? 'voice-bubble' : ''} ${isImage ? 'image-message-bubble' : ''}`}
|
||||
>
|
||||
{isVoice && msg.sessionId ? (
|
||||
<VoicePlayer
|
||||
sessionId={msg.sessionId}
|
||||
localId={msg.localId || 0}
|
||||
createTime={msg.createTime || 0}
|
||||
/>
|
||||
<DataTrustBar messageCount={messages.length} />
|
||||
<MessageList
|
||||
contact={contact}
|
||||
messages={renderedMessages}
|
||||
hiddenMessageCount={hiddenMessageCount}
|
||||
isLoadingMessages={isLoadingMessages}
|
||||
isGroupChat={isGroupChat}
|
||||
showAvatar={showAvatar}
|
||||
listRef={messageListRef}
|
||||
bottomRef={messagesEndRef}
|
||||
onScroll={handleMessageListScroll}
|
||||
) : isImage && msg.contentData && msg.contentData.type === 'image' ? (
|
||||
<ImageBubble
|
||||
imageMd5={msg.contentData.md5}
|
||||
imageDatName={msg.contentData.datName}
|
||||
sessionId={msg.sessionId}
|
||||
onImageClick={openImagePreview}
|
||||
/>
|
||||
<ChatStatusBar
|
||||
count={renderedMessages.length}
|
||||
showAvatar={showAvatar}
|
||||
isAtLatest={isAtLatest}
|
||||
onShowAvatarChange={setShowAvatar}
|
||||
onJumpToLatest={scrollToBottom}
|
||||
) : isRichMedia && msg.contentData ? (
|
||||
<RichMessageBubble contentData={msg.contentData} />
|
||||
) : (
|
||||
<div className="message-text">{msg.content}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="message-meta">
|
||||
<span>{msg.datetime}</span>
|
||||
<span>{msg.type}</span>
|
||||
</div>
|
||||
</div>
|
||||
{isMine && showAvatar && (
|
||||
<div className="message-avatar mine-avatar">
|
||||
{avatarSrc ? <img src={avatarSrc} alt="我" referrerPolicy="no-referrer" /> : '我'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="chat-toolbar">
|
||||
<label
|
||||
style={{ marginRight: '10px', display: 'flex', alignItems: 'center', cursor: 'pointer' }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showAvatar}
|
||||
onChange={(e) => setShowAvatar(e.target.checked)}
|
||||
style={{ marginRight: '5px' }}
|
||||
/>
|
||||
显示头像
|
||||
</label>
|
||||
<button className="toolbar-btn" onClick={onRefresh}>
|
||||
🔄 刷新聊天记录
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={onRefreshData}>
|
||||
🔄 刷新数据
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={() => handleExport('all')}>
|
||||
📤 导出全部
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={() => handleExport(0)}>
|
||||
🕒 导出今日
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={() => handleExport(1)}>
|
||||
📅 导出昨日
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={() => handleExport(7)}>
|
||||
📅 导出近7天
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={() => handleExport(30)}>
|
||||
📅 导出近30天
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={() => setShowSettingsModal(true)}>
|
||||
🤖 AI总结群聊
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 加载模态框 */}
|
||||
{isLoading && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content" style={{ textAlign: 'center', minWidth: '200px' }}>
|
||||
<div style={{ fontSize: '40px', marginBottom: '20px' }}>🤖</div>
|
||||
<div style={{ fontSize: '16px', color: '#333' }}>正在生成群聊日报...</div>
|
||||
<div style={{ fontSize: '12px', color: '#999', marginTop: '10px' }}>
|
||||
正在分析记录、处理头像并生成 HTML 和长图
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片预览模态框 */}
|
||||
{generatedImage && (
|
||||
<div className="modal-overlay" onClick={() => setGeneratedImage(null)}>
|
||||
<div className="modal-content image-preview-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="report-preview-frame">
|
||||
<div className="report-preview-scroller">
|
||||
<img
|
||||
src={generatedImage}
|
||||
alt="Generated Summary"
|
||||
className="report-preview-image"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="report-preview-actions">
|
||||
<button
|
||||
onClick={handleCopyImage}
|
||||
style={{
|
||||
padding: '8px 15px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: '#4CAF50',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
📋 复制图片
|
||||
</button>
|
||||
{reportPaths && (
|
||||
<button
|
||||
onClick={() => window.api.revealGroupReport(reportPaths.pngPath)}
|
||||
style={{ padding: '5px 10px', cursor: 'pointer' }}
|
||||
>
|
||||
在文件夹中显示
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setGeneratedImage(null)}
|
||||
style={{ padding: '5px 10px', cursor: 'pointer' }}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewImage && (
|
||||
<div className="image-viewer-overlay" onClick={closeImagePreview}>
|
||||
@@ -319,7 +515,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={imageViewerStageRef}
|
||||
className="image-viewer-stage"
|
||||
onWheel={handleViewerWheel}
|
||||
onMouseDown={handleViewerMouseDown}
|
||||
@@ -339,6 +534,126 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Settings Modal */}
|
||||
{showSettingsModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowSettingsModal(false)}>
|
||||
<div className="modal-content ai-settings-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>AI 设置</h3>
|
||||
<div className="ai-filter-section">
|
||||
<div className="ai-filter-label">时间范围</div>
|
||||
<div className="ai-date-options">
|
||||
{SUMMARY_DATE_OPTIONS.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className={summaryDateRange === option.value ? 'selected' : ''}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="summary-date-range"
|
||||
value={option.value}
|
||||
checked={summaryDateRange === option.value}
|
||||
onChange={() => setSummaryDateRange(option.value)}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ai-filter-section">
|
||||
<div className="ai-filter-label">报告模式</div>
|
||||
<div className="ai-date-options">
|
||||
<label className={reportMode === 'compact' ? 'selected' : ''}>
|
||||
<input
|
||||
type="radio"
|
||||
name="report-mode"
|
||||
value="compact"
|
||||
checked={reportMode === 'compact'}
|
||||
onChange={() => setReportMode('compact')}
|
||||
/>
|
||||
精简版(推荐)
|
||||
</label>
|
||||
<label className={reportMode === 'full' ? 'selected' : ''}>
|
||||
<input
|
||||
type="radio"
|
||||
name="report-mode"
|
||||
value="full"
|
||||
checked={reportMode === 'full'}
|
||||
onChange={() => setReportMode('full')}
|
||||
/>
|
||||
完整版
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ai-filter-section">
|
||||
<div className="ai-filter-label">消息类型</div>
|
||||
<div className="ai-type-options">
|
||||
{SUMMARY_TYPE_OPTIONS.map((option) => (
|
||||
<label key={option.value}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={summaryMessageTypes.includes(option.value)}
|
||||
onChange={() => toggleSummaryMessageType(option.value)}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: '15px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px' }}>模型服务:</label>
|
||||
<select
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
style={{ width: '100%', padding: '8px' }}
|
||||
>
|
||||
<option value="deepseek-v4-pro">DeepSeek V4 Pro</option>
|
||||
<option value="deepseek-v4-flash">DeepSeek V4 Flash</option>
|
||||
<option value="gpt-4o">GPT-4o</option>
|
||||
<option value="gpt-4o-mini">GPT-4o Mini</option>
|
||||
<option value="gpt-4-turbo">GPT-4 Turbo</option>
|
||||
<option value="claude-3-5-sonnet-20240620">Claude 3.5 Sonnet</option>
|
||||
<option value="moonshot-v1-8k">Moonshot V1</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: '15px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px' }}>Base URL:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={baseURL}
|
||||
onChange={(e) => setBaseURL(e.target.value)}
|
||||
placeholder="https://api.deepseek.com"
|
||||
style={{ width: '95%', padding: '8px' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: '20px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px' }}>API Key:</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="Enter your API Key"
|
||||
style={{ width: '95%', padding: '8px' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px' }}>
|
||||
<button onClick={() => setShowSettingsModal(false)}>取消</button>
|
||||
<button
|
||||
onClick={handleSaveSettings}
|
||||
style={{
|
||||
backgroundColor: '#4CAF50',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '8px 15px',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
生成总结
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
import { Contact, Message } from '../../../shared/types'
|
||||
import {
|
||||
GroupDailyReport,
|
||||
GroupReportMetadata,
|
||||
ReportFunBadge,
|
||||
ReportMediaGalleryItem,
|
||||
ReportMode,
|
||||
ReportSpeakerRank,
|
||||
ReportVoiceHighlight,
|
||||
ReportVoiceLeaderboardItem
|
||||
} from '../../../shared/group-report'
|
||||
|
||||
export interface GroupReportTranscriptRow {
|
||||
id: string
|
||||
datetime: string
|
||||
timestamp: number
|
||||
sender: string
|
||||
content: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export interface GroupReportFactsSnapshot {
|
||||
metadata: GroupReportMetadata
|
||||
transcriptRows: GroupReportTranscriptRow[]
|
||||
topSpeakers: ReportSpeakerRank[]
|
||||
activeTimeline: string
|
||||
media: GroupDailyReport['media']
|
||||
voiceLeaderboard: ReportVoiceLeaderboardItem[]
|
||||
factsPrompt: string
|
||||
}
|
||||
|
||||
export const isInternalIdentifier = (value: string): boolean =>
|
||||
/@chatroom$/i.test(value) || /^wxid_/i.test(value) || /^[a-z0-9_-]{18,}$/i.test(value)
|
||||
|
||||
export const summarySender = (message: Message, contact: Contact | null, isGroup: boolean): string => {
|
||||
if (message.from === 'assistant') {
|
||||
const ownGroupNickname = message.name?.trim()
|
||||
if (isGroup && ownGroupNickname && !isInternalIdentifier(ownGroupNickname)) {
|
||||
return ownGroupNickname
|
||||
}
|
||||
return '我'
|
||||
}
|
||||
const candidate = isGroup ? message.name : contact?.m_nsNickName
|
||||
if (!candidate || isInternalIdentifier(candidate)) return isGroup ? '未命名群成员' : '对方'
|
||||
return candidate
|
||||
}
|
||||
|
||||
export const summaryContent = (message: Message): string => {
|
||||
const data = message.contentData
|
||||
if (!data) return message.content?.trim() || `[${message.type || '消息'}]`
|
||||
|
||||
switch (data.type) {
|
||||
case 'image':
|
||||
return '[图片]'
|
||||
case 'sticker':
|
||||
return '[表情]'
|
||||
case 'voice':
|
||||
return `[语音${data.duration ? ` ${data.duration}秒` : ''}]`
|
||||
case 'share':
|
||||
return `[分享] ${data.title}${data.des ? `:${data.des}` : ''}`
|
||||
case 'quote': {
|
||||
const reply = data.title || data.content || message.content || '[回复]'
|
||||
const quotedSender =
|
||||
data.quotedSender && !isInternalIdentifier(data.quotedSender) ? data.quotedSender : '群成员'
|
||||
return `${reply}(引用 ${quotedSender}:${data.quotedContent || `[引用${data.quotedType || '消息'}]`})`
|
||||
}
|
||||
case 'location':
|
||||
return `[位置] ${data.poiname || data.label || '位置消息'}`
|
||||
case 'card':
|
||||
return `[名片] ${data.nickname || '微信名片'}`
|
||||
case 'voip':
|
||||
return `[通话] ${data.status}${data.duration ? `,${data.duration}秒` : ''}`
|
||||
case 'system':
|
||||
case 'text':
|
||||
return data.content
|
||||
case 'unknown':
|
||||
return `[${message.type || '未知消息'}]`
|
||||
}
|
||||
|
||||
return `[${message.type || '消息'}]`
|
||||
}
|
||||
|
||||
export const parseTimestamp = (message: Message): number => {
|
||||
const value = new Date(message.datetime).getTime()
|
||||
return Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
export const localDate = (timestamp: number): string => {
|
||||
const date = new Date(timestamp)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
export const localTime = (timestamp: number): string =>
|
||||
new Date(timestamp).toLocaleTimeString('zh-CN', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
|
||||
export const resolveVoiceDuration = (message: Message): number => {
|
||||
const fromData = message.contentData?.type === 'voice' ? message.contentData.duration : undefined
|
||||
return Math.max(0, Number(fromData ?? message.voiceDuration ?? 0) || 0)
|
||||
}
|
||||
|
||||
const truncate = (value: string, max = 48): string =>
|
||||
value.length > max ? `${value.slice(0, max - 1)}…` : value
|
||||
|
||||
const buildImageContext = (
|
||||
messages: Message[],
|
||||
index: number,
|
||||
contact: Contact | null,
|
||||
isGroup: boolean
|
||||
): {
|
||||
note: string
|
||||
stats: string
|
||||
responseCount: number
|
||||
participantCount: number
|
||||
snippets: string[]
|
||||
} => {
|
||||
const baseTime = parseTimestamp(messages[index])
|
||||
const participants = new Set<string>()
|
||||
const snippets: string[] = []
|
||||
let responseCount = 0
|
||||
|
||||
for (let offset = index + 1; offset < messages.length && offset <= index + 8; offset++) {
|
||||
const candidate = messages[offset]
|
||||
const candidateTime = parseTimestamp(candidate)
|
||||
if (baseTime && candidateTime && candidateTime - baseTime > 20 * 60 * 1000) break
|
||||
if (candidate.type === '系统消息' || candidate.from === 'system') continue
|
||||
|
||||
const sender = summarySender(candidate, contact, isGroup)
|
||||
const sameSender = sender === summarySender(messages[index], contact, isGroup)
|
||||
const content = summaryContent(candidate)
|
||||
if (!sameSender) {
|
||||
responseCount += 1
|
||||
participants.add(sender)
|
||||
}
|
||||
if (
|
||||
snippets.length < 3 &&
|
||||
!content.startsWith('[图片]') &&
|
||||
!content.startsWith('[表情]') &&
|
||||
!content.startsWith('[语音')
|
||||
) {
|
||||
snippets.push(truncate(content, 28))
|
||||
}
|
||||
}
|
||||
|
||||
const note = snippets.length
|
||||
? `图片发出后,群里接着聊到:${snippets.join(' / ')}`
|
||||
: responseCount > 0
|
||||
? '图片发出后引发了一波接续讨论。'
|
||||
: '这张图片更多像是一次轻量分享,没有形成长链路讨论。'
|
||||
|
||||
const statsParts: string[] = []
|
||||
if (responseCount > 0) statsParts.push(`${responseCount} 条后续消息`)
|
||||
if (participants.size > 0) statsParts.push(`${participants.size} 人接话`)
|
||||
if (!statsParts.length) statsParts.push('讨论热度较低')
|
||||
|
||||
return {
|
||||
note,
|
||||
stats: statsParts.join(' · '),
|
||||
responseCount,
|
||||
participantCount: participants.size,
|
||||
snippets
|
||||
}
|
||||
}
|
||||
|
||||
const buildMediaSection = async (
|
||||
messages: Message[],
|
||||
contact: Contact | null,
|
||||
isGroup: boolean,
|
||||
topSpeakersMap: Map<string, number>
|
||||
): Promise<{
|
||||
media: GroupDailyReport['media']
|
||||
voiceLeaderboard: ReportVoiceLeaderboardItem[]
|
||||
}> => {
|
||||
const rawImageCandidates = messages
|
||||
.map((message, index) => {
|
||||
if (message.contentData?.type !== 'image') return null
|
||||
const sender = summarySender(message, contact, isGroup)
|
||||
const context = buildImageContext(messages, index, contact, isGroup)
|
||||
return {
|
||||
sourceMessageIds: [message.id],
|
||||
md5: message.contentData.md5,
|
||||
datName: message.contentData.datName,
|
||||
sessionId: message.sessionId,
|
||||
sender,
|
||||
time: localTime(parseTimestamp(message)),
|
||||
note: context.note,
|
||||
stats: context.stats,
|
||||
replyCount: context.responseCount,
|
||||
score: context.responseCount * 3 + context.participantCount * 2 + 1
|
||||
}
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => Boolean(item))
|
||||
.sort((left, right) => right.score - left.score)
|
||||
.slice(0, 6)
|
||||
|
||||
const imageCandidates = await Promise.all(
|
||||
rawImageCandidates.map(async (item) => {
|
||||
const result = await window.api.getImage(item.md5, item.datName, item.sessionId)
|
||||
if (!result.success || !result.data?.startsWith('data:image/')) return null
|
||||
return {
|
||||
sender: item.sender,
|
||||
time: item.time,
|
||||
imageUrl: result.data,
|
||||
note: item.note,
|
||||
stats: item.stats,
|
||||
inferenceLabel: '基于图片后的聊天上下文推断',
|
||||
sourceMessageIds: item.sourceMessageIds,
|
||||
replyCount: item.replyCount,
|
||||
score: item.score
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const gallery: ReportMediaGalleryItem[] = imageCandidates
|
||||
.filter((item): item is NonNullable<typeof item> => Boolean(item))
|
||||
.sort((left, right) => right.score - left.score)
|
||||
.slice(0, 4)
|
||||
.map(({ score: _score, ...item }) => item)
|
||||
|
||||
const voiceMessages = messages
|
||||
.filter((message) => message.contentData?.type === 'voice')
|
||||
.map((message) => ({
|
||||
sender: summarySender(message, contact, isGroup),
|
||||
duration: resolveVoiceDuration(message),
|
||||
time: localTime(parseTimestamp(message))
|
||||
}))
|
||||
|
||||
const voiceTotals = new Map<string, { count: number; duration: number }>()
|
||||
for (const item of voiceMessages) {
|
||||
const current = voiceTotals.get(item.sender) || { count: 0, duration: 0 }
|
||||
current.count += 1
|
||||
current.duration += item.duration
|
||||
voiceTotals.set(item.sender, current)
|
||||
}
|
||||
|
||||
const voiceLeaderboard: ReportVoiceLeaderboardItem[] = Array.from(voiceTotals.entries())
|
||||
.map(([sender, value]) => ({
|
||||
sender,
|
||||
count: value.count,
|
||||
durationSec: value.duration
|
||||
}))
|
||||
.sort((left, right) => right.durationSec - left.durationSec || right.count - left.count)
|
||||
.slice(0, 5)
|
||||
|
||||
let bestStreak: { sender: string; count: number; duration: number; time: string } | null = null
|
||||
let currentStreak: { sender: string; count: number; duration: number; time: string } | null = null
|
||||
for (const message of messages) {
|
||||
if (message.contentData?.type !== 'voice') {
|
||||
currentStreak = null
|
||||
continue
|
||||
}
|
||||
const sender = summarySender(message, contact, isGroup)
|
||||
const duration = resolveVoiceDuration(message)
|
||||
const time = localTime(parseTimestamp(message))
|
||||
if (currentStreak && currentStreak.sender === sender) {
|
||||
currentStreak.count += 1
|
||||
currentStreak.duration += duration
|
||||
} else {
|
||||
currentStreak = { sender, count: 1, duration, time }
|
||||
}
|
||||
if (!bestStreak || currentStreak.count > bestStreak.count) {
|
||||
bestStreak = { ...currentStreak }
|
||||
}
|
||||
}
|
||||
|
||||
const voiceHighlights: ReportVoiceHighlight[] = []
|
||||
if (voiceLeaderboard[0]) {
|
||||
voiceHighlights.push({
|
||||
title: '语音输出王',
|
||||
sender: voiceLeaderboard[0].sender,
|
||||
note: `共发送 ${voiceLeaderboard[0].count} 条语音,累计 ${voiceLeaderboard[0].durationSec} 秒。`
|
||||
})
|
||||
}
|
||||
if (bestStreak && bestStreak.count >= 2) {
|
||||
voiceHighlights.push({
|
||||
title: '连续发言时刻',
|
||||
sender: bestStreak.sender,
|
||||
note: `${bestStreak.time} 连发 ${bestStreak.count} 条语音,共 ${bestStreak.duration} 秒。`
|
||||
})
|
||||
}
|
||||
|
||||
const funBadges: ReportFunBadge[] = []
|
||||
const topSpeaker = Array.from(topSpeakersMap.entries()).sort((left, right) => right[1] - left[1])[0]
|
||||
if (topSpeaker) {
|
||||
funBadges.push({
|
||||
title: '高能输出王',
|
||||
owner: topSpeaker[0],
|
||||
note: `今天一共发了 ${topSpeaker[1]} 条消息。`
|
||||
})
|
||||
}
|
||||
if (gallery[0]) {
|
||||
funBadges.push({
|
||||
title: '图片话题王',
|
||||
owner: gallery[0].sender,
|
||||
note: `${gallery[0].time} 的图片带动了最明显的一轮讨论。`
|
||||
})
|
||||
}
|
||||
if (voiceLeaderboard[0]) {
|
||||
funBadges.push({
|
||||
title: '语音麦霸',
|
||||
owner: voiceLeaderboard[0].sender,
|
||||
note: `语音总时长暂居第一,适合放进“今日声音档案”。`
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
media: {
|
||||
gallery,
|
||||
voiceHighlights: voiceHighlights.slice(0, 2),
|
||||
funBadges: funBadges.slice(0, 3)
|
||||
},
|
||||
voiceLeaderboard
|
||||
}
|
||||
}
|
||||
|
||||
const collectQuestionCandidates = (
|
||||
messages: Message[],
|
||||
contact: Contact | null,
|
||||
isGroup: boolean
|
||||
): string[] =>
|
||||
messages
|
||||
.map((message) => ({
|
||||
id: message.id,
|
||||
sender: summarySender(message, contact, isGroup),
|
||||
content: summaryContent(message)
|
||||
}))
|
||||
.filter((item) => /[??]$/.test(item.content) || item.content.includes('吗') || item.content.includes('怎么'))
|
||||
.slice(-6)
|
||||
.map((item) => `${item.sender}(${item.id}):${truncate(item.content, 32)}`)
|
||||
|
||||
const collectReplyFacts = (messages: Message[], contact: Contact | null, isGroup: boolean): string[] =>
|
||||
messages
|
||||
.filter((message) => message.contentData?.type === 'quote' && message.contentData.quotedSender)
|
||||
.slice(0, 10)
|
||||
.map((message) => {
|
||||
const sender = summarySender(message, contact, isGroup)
|
||||
const quotedSender =
|
||||
message.contentData?.type === 'quote' && message.contentData.quotedSender
|
||||
? message.contentData.quotedSender
|
||||
: '群成员'
|
||||
return `${sender} 回复了 ${quotedSender}`
|
||||
})
|
||||
|
||||
export const buildGroupReportFacts = async (
|
||||
messages: Message[],
|
||||
contact: Contact | null,
|
||||
isGroup: boolean,
|
||||
reportMode: ReportMode
|
||||
): Promise<GroupReportFactsSnapshot> => {
|
||||
const transcriptRows = messages.map((message) => ({
|
||||
id: message.id,
|
||||
datetime: message.datetime,
|
||||
timestamp: parseTimestamp(message),
|
||||
sender: summarySender(message, contact, isGroup),
|
||||
content: summaryContent(message),
|
||||
avatar: message.img
|
||||
}))
|
||||
|
||||
let firstTimestamp = Number.POSITIVE_INFINITY
|
||||
let lastTimestamp = Number.NEGATIVE_INFINITY
|
||||
for (const row of transcriptRows) {
|
||||
if (!Number.isFinite(row.timestamp)) continue
|
||||
firstTimestamp = Math.min(firstTimestamp, row.timestamp)
|
||||
lastTimestamp = Math.max(lastTimestamp, row.timestamp)
|
||||
}
|
||||
if (!Number.isFinite(firstTimestamp)) firstTimestamp = Date.now()
|
||||
if (!Number.isFinite(lastTimestamp)) lastTimestamp = firstTimestamp
|
||||
|
||||
const speakerCounts = new Map<string, number>()
|
||||
const hourCounts = new Map<number, number>()
|
||||
const avatars: Record<string, string | undefined> = {}
|
||||
let imageCount = 0
|
||||
let stickerCount = 0
|
||||
let voiceCount = 0
|
||||
let voiceDurationSec = 0
|
||||
|
||||
for (const message of messages) {
|
||||
const sender = summarySender(message, contact, isGroup)
|
||||
const timestamp = parseTimestamp(message)
|
||||
speakerCounts.set(sender, (speakerCounts.get(sender) || 0) + 1)
|
||||
if (Number.isFinite(timestamp)) {
|
||||
const hour = new Date(timestamp).getHours()
|
||||
hourCounts.set(hour, (hourCounts.get(hour) || 0) + 1)
|
||||
}
|
||||
if (message.img && !avatars[sender]) avatars[sender] = message.img
|
||||
if (message.contentData?.type === 'image') imageCount += 1
|
||||
if (message.contentData?.type === 'sticker') stickerCount += 1
|
||||
if (message.contentData?.type === 'voice') {
|
||||
voiceCount += 1
|
||||
voiceDurationSec += resolveVoiceDuration(message)
|
||||
}
|
||||
}
|
||||
|
||||
const topSpeakers = Array.from(speakerCounts, ([name, count]) => ({ name, count }))
|
||||
.sort((left, right) => right.count - left.count)
|
||||
.slice(0, 5)
|
||||
|
||||
const activeTimeline = Array.from(hourCounts, ([hour, count]) => ({ hour, count }))
|
||||
.sort((left, right) => right.count - left.count)
|
||||
.slice(0, 4)
|
||||
.sort((left, right) => left.hour - right.hour)
|
||||
.map(
|
||||
({ hour, count }) =>
|
||||
`${String(hour).padStart(2, '0')}:00-${String(hour).padStart(2, '0')}:59(${count}条)`
|
||||
)
|
||||
.join('、')
|
||||
|
||||
const startDate = localDate(firstTimestamp)
|
||||
const endDate = localDate(lastTimestamp)
|
||||
const sameDay = startDate === endDate
|
||||
const dateRange = sameDay
|
||||
? `${startDate} ${localTime(firstTimestamp)}-${localTime(lastTimestamp)}`
|
||||
: `${startDate} ${localTime(firstTimestamp)} 至 ${endDate} ${localTime(lastTimestamp)}`
|
||||
const durationMs = Math.max(0, lastTimestamp - firstTimestamp)
|
||||
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 groupName = contactName && !isInternalIdentifier(contactName) ? contactName : '未命名会话'
|
||||
const metadata: GroupReportMetadata = {
|
||||
groupName,
|
||||
reportDate: sameDay ? startDate : `${startDate}_to_${endDate}`,
|
||||
dateRange,
|
||||
messageCount: transcriptRows.length,
|
||||
activeUsers: speakerCounts.size,
|
||||
imageCount,
|
||||
voiceCount,
|
||||
stickerCount,
|
||||
mediaMessageCount: imageCount + voiceCount + stickerCount,
|
||||
timeSpan,
|
||||
generatedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
|
||||
recordNote: `基于当前已加载的 ${transcriptRows.length} 条记录`,
|
||||
footerNote: '基于已读取聊天记录生成;图片、表情等未解析内容默认只按类型与上下文参与日报。',
|
||||
heroParticipants: topSpeakers.slice(0, 4).map((speaker) => speaker.name),
|
||||
avatars,
|
||||
reportMode
|
||||
}
|
||||
|
||||
const { media, voiceLeaderboard } = await buildMediaSection(messages, contact, isGroup, speakerCounts)
|
||||
|
||||
const factsPrompt = [
|
||||
`报告模式:${reportMode === 'compact' ? '精简版(30秒可读完)' : '完整版(保留更多上下文)'}`,
|
||||
`消息统计:共 ${transcriptRows.length} 条,活跃成员 ${speakerCounts.size} 人,图片 ${imageCount} 张,表情 ${stickerCount} 条,语音 ${voiceCount} 条(累计 ${voiceDurationSec} 秒)。`,
|
||||
activeTimeline ? `活跃时段:${activeTimeline}` : '',
|
||||
media.gallery.length
|
||||
? `图片观察:${media.gallery.map((item) => `${item.time} ${item.sender} 发图(${item.stats})`).join(';')}`
|
||||
: '',
|
||||
voiceLeaderboard.length
|
||||
? `语音榜:${voiceLeaderboard
|
||||
.slice(0, 3)
|
||||
.map((item) => `${item.sender} ${item.count} 条 / ${item.durationSec} 秒`)
|
||||
.join(';')}`
|
||||
: '',
|
||||
collectQuestionCandidates(messages, contact, isGroup).length
|
||||
? `疑似待跟进问题:${collectQuestionCandidates(messages, contact, isGroup).join(';')}`
|
||||
: '',
|
||||
collectReplyFacts(messages, contact, isGroup).length
|
||||
? `回复关系样本:${collectReplyFacts(messages, contact, isGroup).join(';')}`
|
||||
: ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
return {
|
||||
metadata,
|
||||
transcriptRows,
|
||||
topSpeakers,
|
||||
activeTimeline,
|
||||
media,
|
||||
voiceLeaderboard,
|
||||
factsPrompt
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+168
-4
@@ -1,4 +1,35 @@
|
||||
export type ReportHeat = '高' | '中' | '低'
|
||||
export type ReportMode = 'compact' | 'full'
|
||||
export type ReportSectionKey =
|
||||
| 'hero'
|
||||
| 'topics'
|
||||
| 'importantMessages'
|
||||
| 'actions'
|
||||
| 'moments'
|
||||
| 'analytics'
|
||||
| 'keywords'
|
||||
| 'resources'
|
||||
| 'qa'
|
||||
| 'storylines'
|
||||
| 'reversals'
|
||||
| 'gallery'
|
||||
| 'voices'
|
||||
| 'badges'
|
||||
| 'chains'
|
||||
|
||||
export interface ReportSectionMeta {
|
||||
enabled: boolean
|
||||
importance: number
|
||||
confidence: number
|
||||
totalCount: number
|
||||
displayedCount: number
|
||||
hiddenCount?: number
|
||||
}
|
||||
|
||||
export interface ReportTopicConclusion {
|
||||
text: string
|
||||
sourceMessageIds?: string[]
|
||||
}
|
||||
|
||||
export interface ReportTopic {
|
||||
title: string
|
||||
@@ -7,13 +38,21 @@ export interface ReportTopic {
|
||||
participants: string[]
|
||||
summary: string
|
||||
conclusion?: string
|
||||
conclusions?: ReportTopicConclusion[]
|
||||
keywords: string[]
|
||||
sourceMessageIds?: string[]
|
||||
image?: {
|
||||
imageUrl?: string
|
||||
note: string
|
||||
sourceMessageIds?: string[]
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface ReportResource {
|
||||
title: string
|
||||
description: string
|
||||
sender?: string
|
||||
sourceMessageIds?: string[]
|
||||
}
|
||||
|
||||
export interface ReportImportantMessage {
|
||||
@@ -21,22 +60,30 @@ export interface ReportImportantMessage {
|
||||
time: string
|
||||
content: string
|
||||
note: string
|
||||
sourceMessageIds?: string[]
|
||||
importance?: number
|
||||
confidence?: number
|
||||
}
|
||||
|
||||
export interface ReportQuoteMessage {
|
||||
sender: string
|
||||
content: string
|
||||
sourceMessageId?: string
|
||||
}
|
||||
|
||||
export interface ReportQuote {
|
||||
messages: ReportQuoteMessage[]
|
||||
note: string
|
||||
sourceMessageIds?: string[]
|
||||
importance?: number
|
||||
confidence?: number
|
||||
}
|
||||
|
||||
export interface ReportQuestionAnswer {
|
||||
question: string
|
||||
answer: string
|
||||
answerer?: string
|
||||
sourceMessageIds?: string[]
|
||||
}
|
||||
|
||||
export interface ReportTopicHeat {
|
||||
@@ -49,19 +96,135 @@ export interface ReportSpeakerRank {
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface ReportHero {
|
||||
headline: string
|
||||
summary: string
|
||||
keyTakeaway?: string
|
||||
pendingNote?: string
|
||||
statusLine?: string
|
||||
}
|
||||
|
||||
export interface ReportMediaGalleryItem {
|
||||
sender: string
|
||||
time: string
|
||||
imageUrl: string
|
||||
note: string
|
||||
stats?: string
|
||||
inferenceLabel?: string
|
||||
sourceMessageIds?: string[]
|
||||
replyCount?: number
|
||||
}
|
||||
|
||||
export interface ReportVoiceHighlight {
|
||||
title: string
|
||||
sender: string
|
||||
note: string
|
||||
sourceMessageIds?: string[]
|
||||
}
|
||||
|
||||
export interface ReportVoiceLeaderboardItem {
|
||||
sender: string
|
||||
count: number
|
||||
durationSec: number
|
||||
}
|
||||
|
||||
export interface ReportFunBadge {
|
||||
title: string
|
||||
owner: string
|
||||
note: string
|
||||
}
|
||||
|
||||
export interface ReportTodoItem {
|
||||
task: string
|
||||
owner?: string | null
|
||||
deadline?: string | null
|
||||
topic?: string | null
|
||||
note?: string
|
||||
sourceMessageIds?: string[]
|
||||
importance?: number
|
||||
confidence?: number
|
||||
}
|
||||
|
||||
export interface ReportUnresolvedItem {
|
||||
question: string
|
||||
owner?: string
|
||||
status: '待跟进' | '暂未回答' | '进行中'
|
||||
note: string
|
||||
lastDiscussedAt?: string | null
|
||||
sourceMessageIds?: string[]
|
||||
importance?: number
|
||||
confidence?: number
|
||||
}
|
||||
|
||||
export interface ReportStorylineStage {
|
||||
time: string
|
||||
event: string
|
||||
sourceMessageIds?: string[]
|
||||
}
|
||||
|
||||
export interface ReportStoryline {
|
||||
title: string
|
||||
stages: ReportStorylineStage[]
|
||||
result?: string
|
||||
sourceMessageIds?: string[]
|
||||
}
|
||||
|
||||
export interface ReportReversal {
|
||||
topic: string
|
||||
initialView: string
|
||||
finalView: string
|
||||
note?: string
|
||||
sourceMessageIds?: string[]
|
||||
}
|
||||
|
||||
export interface ReportParticipantChain {
|
||||
topic: string
|
||||
chain: string[]
|
||||
note?: string
|
||||
sourceMessageIds?: string[]
|
||||
}
|
||||
|
||||
export interface ReportSummaryStats {
|
||||
messageCount: number
|
||||
activeUsers: number
|
||||
topicCount: number
|
||||
mediaCount: number
|
||||
imageCount: number
|
||||
voiceCount: number
|
||||
stickerCount: number
|
||||
conclusionCount: number
|
||||
todoCount: number
|
||||
unresolvedCount: number
|
||||
}
|
||||
|
||||
export interface GroupDailyReport {
|
||||
overview: string
|
||||
mode?: ReportMode
|
||||
hero?: ReportHero
|
||||
topics: ReportTopic[]
|
||||
resources: ReportResource[]
|
||||
importantMessages: ReportImportantMessage[]
|
||||
quotes: ReportQuote[]
|
||||
qa: ReportQuestionAnswer[]
|
||||
todos: ReportTodoItem[]
|
||||
unresolved: ReportUnresolvedItem[]
|
||||
storylines: ReportStoryline[]
|
||||
reversals: ReportReversal[]
|
||||
participantChains: ReportParticipantChain[]
|
||||
analytics: {
|
||||
topicHeat: ReportTopicHeat[]
|
||||
activeTimeline: string
|
||||
topSpeakers: ReportSpeakerRank[]
|
||||
voiceLeaderboard: ReportVoiceLeaderboardItem[]
|
||||
}
|
||||
keywords: string[]
|
||||
media: {
|
||||
gallery: ReportMediaGalleryItem[]
|
||||
voiceHighlights: ReportVoiceHighlight[]
|
||||
funBadges: ReportFunBadge[]
|
||||
}
|
||||
summaryStats?: ReportSummaryStats
|
||||
sectionMeta?: Partial<Record<ReportSectionKey, ReportSectionMeta>>
|
||||
}
|
||||
|
||||
export interface GroupReportMetadata {
|
||||
@@ -70,18 +233,19 @@ export interface GroupReportMetadata {
|
||||
dateRange: string
|
||||
messageCount: number
|
||||
activeUsers: number
|
||||
imageCount?: number
|
||||
voiceCount?: number
|
||||
stickerCount?: number
|
||||
mediaMessageCount?: number
|
||||
timeSpan: string
|
||||
generatedAt: string
|
||||
recordNote: string
|
||||
footerNote: string
|
||||
heroParticipants: string[]
|
||||
avatars: Record<string, string | undefined>
|
||||
// === 新增(可选,向后兼容) ===
|
||||
/** 群昵称 / wxid / md5,服务端用来反推真头像(从 getGroupSnapshot) */
|
||||
reportMode?: ReportMode
|
||||
talker?: string
|
||||
/** 预留,与 /api/v1/chatlog 的 time 参数同格式 */
|
||||
timeRange?: string
|
||||
/** 服务端写回,告知 client enrich 失败/部分缺失 */
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user