mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
feat: 群日报 9 宫格头像自动反推 + 持续时长语义 + 自动登录
- group-report-service: enrichAvatarsFromGroup 从群成员快照反推真头像 - group-report-service: 修 SVG data URL 正则,fallback 现在能正常嵌入 - group-report (shared): GroupReportMetadata/Result 加 talker/warnings 字段 - timeSpan 改为持续时长(\"1 h\" / \"30 min\" / \"2 d\" 紧凑半角) - App.tsx 启动自动连接(env var + safeStorage) - Wcdb4Client 父目录自动解析为最新 wxid - HTTP server EADDRINUSE 友好提示 + 指数退避 - installSafeConsole 修 EPIPE crash - SettingsPanel 测试连接后自动更新 dbRoot
This commit is contained in:
@@ -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) }
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Vendored
+1
@@ -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
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user