feat: 群日报 9 宫格头像自动反推 + 持续时长语义 + 自动登录

- group-report-service: enrichAvatarsFromGroup 从群成员快照反推真头像
  - group-report-service: 修 SVG data URL 正则,fallback 现在能正常嵌入
  - group-report (shared): GroupReportMetadata/Result 加 talker/warnings 字段
  - timeSpan 改为持续时长(\"1 h\" / \"30 min\" / \"2 d\" 紧凑半角)
  - App.tsx 启动自动连接(env var + safeStorage)
  - Wcdb4Client 父目录自动解析为最新 wxid
  - HTTP server EADDRINUSE 友好提示 + 指数退避
  - installSafeConsole 修 EPIPE crash
  - SettingsPanel 测试连接后自动更新 dbRoot
This commit is contained in:
电摇小子
2026-07-08 16:40:03 +08:00
parent 7932ea2f54
commit 3eb4438f4c
11 changed files with 573 additions and 35 deletions
+59 -3
View File
@@ -5,8 +5,10 @@ import path from 'path'
import {
GroupReportExportRequest,
GroupReportExportResult,
GroupReportMetadata,
ReportHeat
} from '../shared/group-report'
import { resolveMd5, getGroupSnapshot } from './services/chat-service'
const TEMPLATE_NAME = 'mobile_daily_report.html'
@@ -48,7 +50,7 @@ const imageMimeType = (contentType: string | null, source: string): string => {
const embedAvatar = async (source: string | undefined, name: string): Promise<string> => {
if (!source) return fallbackAvatar(name)
if (/^data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=]+$/i.test(source)) return source
if (/^data:image\/[a-z0-9.+/-]+;base64,[a-z0-9+/=]+$/i.test(source)) return source
try {
if (/^https?:\/\//i.test(source)) {
@@ -84,6 +86,51 @@ const templatePath = (): string => {
return found
}
/**
* 从群成员快照反推真头像,填进 metadata.avatars。
* - 没传 talker → 跳过(向后兼容)
* - talker 解析失败 / snapshot 拿不到 → 200 + warn,继续走 fallback
* - 客户端传的 avatars[name](非空)优先;否则从 snapshot 的 m_nsHeadImgUrl 补
* - 同名取首条(P2 风险:群里两人同名)
*/
const enrichAvatarsFromGroup = async (metadata: GroupReportMetadata): Promise<void> => {
if (!metadata.talker) return
const resolved = resolveMd5(metadata.talker)
if (!resolved) {
metadata.warnings = metadata.warnings ?? []
metadata.warnings.push(`enrich skipped: talker "${metadata.talker}" not found`)
return
}
const snapshot = getGroupSnapshot(resolved.md5)
if (!snapshot) {
metadata.warnings = metadata.warnings ?? []
metadata.warnings.push(
`enrich skipped: group snapshot not available for "${metadata.talker}"`
)
return
}
const index = new Map<string, string>()
for (const member of snapshot.members) {
if (member.nickname && member.avatar && !index.has(member.nickname)) {
index.set(member.nickname, member.avatar)
}
}
metadata.avatars = metadata.avatars ?? {}
for (const [name, url] of index) {
if (metadata.avatars[name]) continue
metadata.avatars[name] = url
}
metadata.warnings = metadata.warnings ?? []
metadata.warnings.push(
`enriched ${index.size} member avatars from snapshot (${snapshot.memberCount} members)`
)
}
const heatClass = (heat: ReportHeat): string => {
if (heat === '高') return 'hot'
if (heat === '低') return 'blue'
@@ -208,7 +255,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
HERO_AVATARS: heroAvatars,
MESSAGE_COUNT: String(metadata.messageCount),
ACTIVE_USERS: String(metadata.activeUsers),
TIME_SPAN: escapeHtml(metadata.timeSpan),
TIME_SPAN: escapeHtml(metadata.timeSpan || ''),
TOPIC_COUNT: String(report.topics.length),
TOPIC_CARDS: topicCards,
RESOURCES_EMPTY_CLASS: report.resources.length ? '' : 'empty-section',
@@ -280,6 +327,9 @@ export const exportGroupReport = async (
request: GroupReportExportRequest
): Promise<GroupReportExportResult> => {
try {
// === enrich 在 render 之前:从群成员快照反推真头像 ===
await enrichAvatarsFromGroup(request.metadata)
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
await fs.ensureDir(outputDir)
const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_可视化长图`
@@ -288,7 +338,13 @@ export const exportGroupReport = async (
const html = await renderReportHtml(request)
await fs.writeFile(htmlPath, html, 'utf8')
const imageDataUrl = await captureFullPage(htmlPath, pngPath)
return { success: true, htmlPath, pngPath, imageDataUrl }
return {
success: true,
htmlPath,
pngPath,
imageDataUrl,
warnings: request.metadata.warnings?.length ? request.metadata.warnings : undefined
}
} catch (error) {
console.error('[GroupReport] export failed:', error)
return { success: false, error: error instanceof Error ? error.message : String(error) }
+2
View File
@@ -120,6 +120,8 @@ app.whenReady().then(async () => {
return databaseKeyStore.save(clipboardKey)
})
ipcMain.handle('key:saveDbKey', async (_, key: string) => databaseKeyStore.save(String(key || '')))
ipcMain.handle('key:clearSavedDbKey', async () => databaseKeyStore.clear())
ipcMain.handle('key:autoGetDbKey', async (event) => {
+1
View File
@@ -78,6 +78,7 @@ declare global {
warning?: string
}>
pasteAndSaveDbKey: () => Promise<{ success: boolean; key?: string; error?: string }>
saveDbKey: (key: string) => Promise<{ success: boolean; key?: string; error?: string }>
clearSavedDbKey: () => Promise<{ success: boolean; error?: string }>
onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => () => void
onDbKeyStatus: (callback: (payload: { message: string }) => void) => () => void
+1
View File
@@ -28,6 +28,7 @@ const api = {
getSavedDbKey: () => ipcRenderer.invoke('key:getSavedDbKey'),
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
pasteAndSaveDbKey: () => ipcRenderer.invoke('key:pasteAndSaveDbKey'),
saveDbKey: (key: string) => ipcRenderer.invoke('key:saveDbKey', key),
clearSavedDbKey: () => ipcRenderer.invoke('key:clearSavedDbKey'),
onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => {
const listener = (
+92 -27
View File
@@ -107,19 +107,84 @@ function App(): React.ReactElement {
const [showSettings, setShowSettings] = useState(false)
const [selfInfo, setSelfInfo] = useState<SelfInfo | null>(null)
const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false)
const [bootState, setBootState] = useState<'loading' | 'connecting' | 'login'>('loading')
const [autoConnectSource, setAutoConnectSource] = useState<'env' | 'saved' | null>(null)
const currentGroupSnapshotRef = React.useRef<GroupSnapshot | null>(null)
const syntheticGroupMessagesRef = React.useRef<Record<string, Message[]>>({})
const refreshSelfInfo = async (): Promise<void> => {
try {
const result = await window.api.getSelf()
if (result.ready) {
setSelfInfo(result.info)
} else {
setSelfInfo(null)
}
} catch (error) {
console.warn('[SelfInfo] 加载失败:', error)
setSelfInfo(null)
}
}
const loadContacts = async (): Promise<void> => {
const list = await window.api.getContacts()
setContacts(list)
setFilteredContacts(list)
}
React.useEffect(() => {
let active = true
void window.api.getSavedDbKey().then((result) => {
if (!active) return
if (result.success && result.key) {
setDbKey(result.key)
setDbKeyStatus('已加载安全保存的密钥')
setDbKeyStatusKind('success')
const attemptAutoConnect = async (): Promise<void> => {
// 优先级 1:构建期环境变量 VITE_DB_KEY(本地开发/打包时硬编码的密钥)
const envKey = String(import.meta.env.VITE_DB_KEY || '').trim()
// 优先级 2:上一次保存到 safeStorage 的密钥
let savedKey = ''
if (!envKey) {
const result = await window.api.getSavedDbKey()
if (result.success && result.key) savedKey = result.key
}
})
const key = envKey || savedKey
if (!key) {
if (active) setBootState('login')
return
}
if (active) {
setBootState('connecting')
setDbKey(key)
setAutoConnectSource(envKey ? 'env' : 'saved')
setDbKeyStatus(
envKey ? '检测到环境变量中的密钥,正在自动连接...' : '已加载安全保存的密钥,正在自动连接...'
)
setDbKeyStatusKind('normal')
}
try {
const result = await window.api.initDb(key)
if (!active) return
const success = typeof result === 'boolean' ? result : result.success
if (success) {
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
setIsAuthenticated(true)
setDbKeyStatus('已自动连接')
setDbKeyStatusKind('success')
await loadContacts()
void refreshSelfInfo()
} else {
const error = typeof result === 'boolean' ? '' : result.error
setDbKeyStatus(
`自动连接失败,请重新输入${error ? `: ${error}` : ''}`
)
setDbKeyStatusKind('error')
setBootState('login')
}
} catch (error) {
if (!active) return
const message = error instanceof Error ? error.message : String(error)
setDbKeyStatus(`自动连接失败: ${message}`)
setDbKeyStatusKind('error')
setBootState('login')
}
}
void attemptAutoConnect()
const unsubscribe = window.api.onDbKeyStatus(({ message }) => {
if (!active) return
setDbKeyStatus(message)
@@ -146,6 +211,8 @@ function App(): React.ReactElement {
if (success) {
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
setIsAuthenticated(true)
// 手动输入也持久化,下次启动可自动连接(参考 WeFlow)
void window.api.saveDbKey(keyToUse).catch(() => undefined)
loadContacts()
void refreshSelfInfo()
} else {
@@ -158,20 +225,6 @@ function App(): React.ReactElement {
}
}
const refreshSelfInfo = async (): Promise<void> => {
try {
const result = await window.api.getSelf()
if (result.ready) {
setSelfInfo(result.info)
} else {
setSelfInfo(null)
}
} catch (error) {
console.warn('[SelfInfo] 加载失败:', error)
setSelfInfo(null)
}
}
const logGroupSnapshot = React.useCallback(
async (contact: Contact | null, reason: string): Promise<GroupSnapshot | null> => {
if (!contact || contact.type !== 'group') return null
@@ -251,12 +304,6 @@ function App(): React.ReactElement {
setDbKeyStatusKind('normal')
}
const loadContacts = async (): Promise<void> => {
const list = await window.api.getContacts()
setContacts(list)
setFilteredContacts(list)
}
const getDateRangeParams = (
range: string
): { startTime: number | undefined; endTime: number | undefined } => {
@@ -434,6 +481,24 @@ function App(): React.ReactElement {
}
}, [resize, stopResizing])
if (!isAuthenticated && bootState !== 'login') {
return (
<div className="boot-splash">
<div className="boot-splash-spinner" aria-hidden />
<div className="boot-splash-title">
{bootState === 'connecting' ? '正在自动连接数据库...' : '正在准备...'}
</div>
<div className="boot-splash-subtitle">
{bootState === 'connecting'
? autoConnectSource === 'env'
? '检测到环境变量中的密钥'
: '使用上次安全保存的密钥'
: 'WechatExplorer'}
</div>
</div>
)
}
if (!isAuthenticated) {
return (
<div className="login-modal">
+49
View File
@@ -1273,6 +1273,55 @@ body {
flex-shrink: 0;
}
/* 启动自动连接 Splash */
.boot-splash {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
background: linear-gradient(180deg, #f5f7f8 0%, #eceff1 100%);
z-index: 2000;
animation: boot-splash-fade-in 0.2s ease-out;
}
@keyframes boot-splash-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.boot-splash-spinner {
width: 36px;
height: 36px;
border-radius: 50%;
border: 3px solid rgba(7, 193, 96, 0.18);
border-top-color: #07c160;
animation: boot-splash-spin 0.9s linear infinite;
}
@keyframes boot-splash-spin {
to {
transform: rotate(360deg);
}
}
.boot-splash-title {
font-size: 15px;
font-weight: 500;
color: #1f2429;
}
.boot-splash-subtitle {
font-size: 12px;
color: #6f767c;
}
/* 设置面板 */
.settings-overlay {
position: fixed;
+15 -3
View File
@@ -169,9 +169,21 @@ export const buildGroupReportInput = (
const dateRange = sameDay
? `${startDate} ${localTime(firstTimestamp)}-${localTime(lastTimestamp)}`
: `${startDate} ${localTime(firstTimestamp)}${endDate} ${localTime(lastTimestamp)}`
const timeSpan = sameDay
? `${Math.max(1, Math.ceil((lastTimestamp - firstTimestamp) / 3600000))}小时`
: `${Math.max(1, Math.ceil((lastTimestamp - firstTimestamp) / 86400000))}`
// 模板"持续时长"格子:首条到末条消息的时长,紧凑半角格式
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 = {
+8
View File
@@ -76,6 +76,13 @@ export interface GroupReportMetadata {
footerNote: string
heroParticipants: string[]
avatars: Record<string, string | undefined>
// === 新增(可选,向后兼容) ===
/** 群昵称 / wxid / md5,服务端用来反推真头像(从 getGroupSnapshot) */
talker?: string
/** 预留,与 /api/v1/chatlog 的 time 参数同格式 */
timeRange?: string
/** 服务端写回,告知 client enrich 失败/部分缺失 */
warnings?: string[]
}
export interface GroupReportExportRequest {
@@ -88,5 +95,6 @@ export interface GroupReportExportResult {
htmlPath?: string
pngPath?: string
imageDataUrl?: string
warnings?: string[]
error?: string
}