Files
WechatExplorer/src/renderer/src/App.tsx
T

1358 lines
47 KiB
TypeScript

import React, { useState } from 'react'
import { Sidebar } from './components/Sidebar'
import ChatWindow from './components/ChatWindow'
import { AppShell } from './components/layout/AppShell'
import { ApiWorkspace } from './features/api-center/ApiWorkspace'
import { SettingsWorkspace } from './features/settings/SettingsWorkspace'
import type { SettingsCategoryId } from './features/settings/model/types'
import type { AIRuntimeModelConfig } from '../../shared/ai-provider'
import { AppPage } from './components/layout/navigation'
import { AiReportWorkspace } from './components/reports/AiReportWorkspace'
import { ReportHistorySidebar } from './components/reports/ReportHistorySidebar'
import { ReportInfoPanel } from './components/reports/ReportInfoPanel'
import { ReportSourceSidebar } from './components/reports/ReportSourceSidebar'
import { ReportTaskStatusPanel } from './components/reports/ReportTaskStatusPanel'
import { ReportViewer } from './components/reports/ReportViewer'
import { contactDisplayName } from './components/reports/types'
import type { GeneratedReportRecord, ReportWorkspaceView } from './components/reports/types'
import { AiModelConfig, useGroupReportGeneration } from './hooks/useGroupReportGeneration'
import { SummaryDateRange, SummaryMessageType } from './utils/group-report'
import { Contact, Message } from '../../shared/types'
const SIDEBAR_MIN_WIDTH = 260
const SIDEBAR_MAX_WIDTH = 380
function EyeIcon({ hidden }: { hidden: boolean }): React.ReactElement {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path
d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" strokeWidth="1.8" />
{hidden && (
<path
d="M4 4l16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
)}
</svg>
)
}
interface SelfInfo {
wxid: string
nickname: string
avatar?: string
accountRoot: string
}
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
const MESSAGE_MONITOR_DEBOUNCE_MS = 8000
const VIEW_MESSAGE_LIMIT = 600
const AUTO_LOGIN_ENABLED = ['1', 'true', 'yes', 'on'].includes(
String(import.meta.env.VITE_AUTO_LOGIN || '')
.trim()
.toLowerCase()
)
const getMessageIdentity = (message: Message): string => {
if (message.localId) return `local:${message.localId}`
if (message.id) return `id:${message.id}`
return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}`
}
const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => {
if (left === right) return true
if (left.length !== right.length) return false
for (let index = 0; index < left.length; index += 1) {
if (getMessageIdentity(left[index]) !== getMessageIdentity(right[index])) return false
}
return true
}
type GroupSnapshot = {
roomId: string
memberCount: number
members: {
wxid: string
nickname: string
groupNickname: string
wechatNickname: string
remark: string
avatar: string
}[]
}
type GroupMemberMeta = { nickname: string; avatar: string }
type StartupProgress = {
title: string
subtitle: string
detail?: string
percent?: number
}
const formatGroupMemberName = (member: GroupSnapshot['members'][number]): string =>
member.nickname || member.wxid
const buildSyntheticGroupMessages = (
previous: GroupSnapshot | null,
next: GroupSnapshot | null,
referenceMessages: Message[]
): Message[] => {
if (!previous || !next || previous.roomId !== next.roomId) return []
const previousMap = new Map(previous.members.map((member) => [member.wxid, member]))
const nextMap = new Map(next.members.map((member) => [member.wxid, member]))
const latestMessageTime = referenceMessages.reduce(
(max, message) => Math.max(max, message.createTime || 0),
0
)
const fallbackNow = Math.floor(Date.now() / 1000)
const events: Message[] = []
let offset = 1
for (const [wxid, member] of previousMap.entries()) {
if (nextMap.has(wxid)) continue
const name = formatGroupMemberName(member)
const eventTime = Math.max(latestMessageTime + offset, fallbackNow)
const eventDate = new Date(eventTime * 1000)
offset += 1
events.push({
id: `synthetic-leave:${next.roomId}:${wxid}:${eventTime}`,
from: 'system',
type: '系统消息',
datetime: eventDate.toLocaleString('zh-CN', { hour12: false }),
content: `${name} 退出了群聊`,
isSender: false,
createTime: eventTime
})
}
if (events.length) {
console.log(
`[GroupMonitor] synthetic leave detected roomId=${next.roomId} events=${events
.map((event) => event.content)
.join(' | ')}`
)
}
return events
}
void buildSyntheticGroupMessages
const sortMessagesChronologically = (items: Message[]): Message[] =>
[...items].sort((left, right) => {
const timeDelta = (left.createTime || 0) - (right.createTime || 0)
if (timeDelta !== 0) return timeDelta
return getMessageIdentity(left).localeCompare(getMessageIdentity(right))
})
function App(): React.ReactElement {
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isDatabaseConnected, setIsDatabaseConnected] = useState(false)
const [dbKey, setDbKey] = useState(import.meta.env.VITE_DB_KEY || '')
const [contacts, setContacts] = useState<Contact[]>([])
const [selectedContact, setSelectedContact] = useState<Contact | null>(null)
const [messages, setMessages] = useState<Message[]>([])
const [isMessagesLoading, setIsMessagesLoading] = useState(false)
const [filteredContacts, setFilteredContacts] = useState<Contact[]>([])
const [dateRange, setDateRange] = useState('today') // 默认今天
const [contentFilter, setContentFilter] = useState('')
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
const [dbKeyStatus, setDbKeyStatus] = useState('')
const [dbKeyStatusKind, setDbKeyStatusKind] = useState<'normal' | 'success' | 'error'>('normal')
const [showDbKey, setShowDbKey] = useState(false)
const [dbRootInput, setDbRootInput] = useState('')
const [showMacKeyFaq, setShowMacKeyFaq] = useState(false)
const [activePage, setActivePage] = useState<AppPage>('archive')
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database')
const [reportSourceContact, setReportSourceContact] = useState<Contact | null>(null)
const [reportWorkspaceView, setReportWorkspaceView] = useState<ReportWorkspaceView>('result')
const [generatedReports, setGeneratedReports] = useState<GeneratedReportRecord[]>([])
const [selectedReportId, setSelectedReportId] = useState<string | null>(null)
const [latestGeneratedReportId, setLatestGeneratedReportId] = useState<string | null>(null)
const [isSavingGeneratedReport, setIsSavingGeneratedReport] = useState(false)
const [reportNotice, setReportNotice] = useState('')
const [summaryDateRange, setSummaryDateRange] = useState<SummaryDateRange>('today')
const [summaryMessageTypes, setSummaryMessageTypes] = useState<SummaryMessageType[]>(['text'])
const [aiModelConfig, setAiModelConfig] = useState<AiModelConfig>({
providerName: '尚未配置',
model: '',
modelName: '尚未选择模型',
configured: false,
status: 'untested'
})
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 [startupProgress, setStartupProgress] = useState<StartupProgress | null>(null)
const currentGroupSnapshotRef = React.useRef<GroupSnapshot | null>(null)
const syntheticGroupMessagesRef = React.useRef<Record<string, Message[]>>({})
const groupMemberMetaRef = React.useRef<Record<string, Map<string, GroupMemberMeta>>>({})
React.useEffect(() => {
if (!reportNotice) return
const timer = window.setTimeout(() => setReportNotice(''), 3200)
return () => window.clearTimeout(timer)
}, [reportNotice])
React.useEffect(() => {
const loadAIConfig = async (): Promise<void> => {
try {
const legacy = {
apiKey: localStorage.getItem('ai_api_key') || undefined,
baseUrl: localStorage.getItem('ai_base_url') || undefined,
model: localStorage.getItem('ai_model') || undefined
}
if (legacy.apiKey || legacy.baseUrl || legacy.model) {
const migrated = await window.api.migrateLegacyAIConfig(legacy)
if (migrated.success) {
localStorage.removeItem('ai_api_key')
localStorage.removeItem('ai_base_url')
localStorage.removeItem('ai_model')
}
}
setAiModelConfig(await window.api.getAIRuntimeConfig())
} catch (error) {
console.warn('[AI Provider] 配置加载失败:', error)
}
}
void loadAIConfig()
}, [])
const selectedContactMd5Ref = React.useRef<string>('')
const contactAvatarHydrationRunRef = React.useRef(0)
const reportGeneration = useGroupReportGeneration({
sourceContact: reportSourceContact,
summaryDateRange,
summaryMessageTypes,
modelConfig: aiModelConfig
})
const lastCapturedReportKeyRef = React.useRef('')
const loadGeneratedReports = React.useCallback(async (): Promise<void> => {
try {
const result = await window.api.listGeneratedReports()
if (!result.success) {
setReportNotice(result.error || '日报历史加载失败')
return
}
const reports = result.reports || []
setGeneratedReports(reports)
setSelectedReportId((current) =>
current && reports.some((report) => report.id === current) ? current : null
)
} catch (error) {
setReportNotice(error instanceof Error ? error.message : String(error))
}
}, [])
const waitForPaint = (): Promise<void> => new Promise((resolve) => window.setTimeout(resolve, 80))
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 loadBootstrapCache = async (): Promise<boolean> => {
try {
const cache = await window.api.getBootstrapCache()
if (!cache) return false
if (cache.contacts.length) {
setContacts(cache.contacts)
setFilteredContacts(cache.contacts)
}
if (cache.self) setSelfInfo(cache.self)
return Boolean(cache.contacts.length || cache.self)
} catch (error) {
console.warn('[BootstrapCache] 加载失败:', error)
return false
}
}
const loadContacts = async (options?: {
waitForAvatars?: boolean
onProgress?: (message: string, percent?: number) => void
}): Promise<void> => {
options?.onProgress?.('正在加载联系人...', 35)
const list = await window.api.getContacts()
setContacts(list)
setFilteredContacts(list)
const runId = ++contactAvatarHydrationRunRef.current
const hydrate = (): Promise<void> => hydrateContactAvatars(list, runId, options?.onProgress)
if (options?.waitForAvatars) {
await hydrate()
} else {
window.setTimeout(() => {
void hydrate()
}, 1500)
}
}
const hydrateContactAvatars = async (
list: Contact[],
runId: number,
onProgress?: (message: string, percent?: number) => void
): Promise<void> => {
const usernames = Array.from(
new Set(
list
.map((contact) => contact.m_nsUsrName)
.filter((username, index) => {
const contact = list[index]
return (
username &&
!contact.avatar &&
!username.startsWith('Group_') &&
!username.startsWith('Unknown_')
)
})
)
)
onProgress?.(
usernames.length ? `正在加载头像 0/${usernames.length}...` : '头像缓存已就绪',
usernames.length ? 55 : 90
)
let loadedCount = 0
const chunkSize = 8
for (let index = 0; index < usernames.length; index += chunkSize) {
if (runId !== contactAvatarHydrationRunRef.current) return
const chunk = usernames.slice(index, index + chunkSize)
if (chunk.length === 0) continue
try {
const avatars = await window.api.getContactAvatars(chunk)
if (runId !== contactAvatarHydrationRunRef.current) return
loadedCount += chunk.length
onProgress?.(
`正在加载头像 ${Math.min(loadedCount, usernames.length)}/${usernames.length}...`,
55 + Math.round((Math.min(loadedCount, usernames.length) / usernames.length) * 35)
)
if (Object.keys(avatars).length === 0) continue
setContacts((current) =>
current.map((contact) =>
avatars[contact.m_nsUsrName]
? { ...contact, avatar: avatars[contact.m_nsUsrName] }
: contact
)
)
setFilteredContacts((current) =>
current.map((contact) =>
avatars[contact.m_nsUsrName]
? { ...contact, avatar: avatars[contact.m_nsUsrName] }
: contact
)
)
} catch (error) {
console.warn('[Contacts] avatar hydrate failed:', error)
}
await new Promise((resolve) => window.setTimeout(resolve, 150))
}
if (usernames.length) onProgress?.('头像加载完成', 90)
}
React.useEffect(() => {
let active = true
const attemptAutoConnect = async (): Promise<void> => {
// 预填已保存的微信聊天文件路径
try {
const settings = await window.api.getSettings()
if (active && settings?.settings?.dbRoot) setDbRootInput(settings.settings.dbRoot)
} catch {
// 忽略读取设置失败,继续走密钥流程
}
// 优先级 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) {
setDbKey(key)
setAutoConnectSource(envKey ? 'env' : 'saved')
setDbKeyStatus(
AUTO_LOGIN_ENABLED
? envKey
? '检测到环境变量中的密钥,正在自动连接...'
: '已加载安全保存的密钥,正在自动连接...'
: envKey
? '已加载环境变量中的密钥,请手动点击 Connect'
: '已加载安全保存的密钥,请手动点击 Connect'
)
setDbKeyStatusKind('normal')
setBootState(AUTO_LOGIN_ENABLED ? 'connecting' : 'login')
}
if (!AUTO_LOGIN_ENABLED) return
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)
setIsDatabaseConnected(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: unknown) {
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)
setDbKeyStatusKind('normal')
})
return () => {
active = false
unsubscribe()
}
// 自动连接只在应用启动时执行一次,避免联系人加载刷新触发重复连接。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
React.useEffect(() => {
if (!isAuthenticated) return
void loadGeneratedReports()
}, [isAuthenticated, loadGeneratedReports])
const handleLogin = async (keyInput?: string): Promise<void> => {
const keyToUse = keyInput || dbKey
if (!keyToUse) return
setBootState('connecting')
// 持久化用户手动指定的微信聊天文件路径,供 db:init 读取 settings.dbRoot
const trimmedRoot = dbRootInput.trim()
if (trimmedRoot) {
try {
await window.api.setSettings({ dbRoot: trimmedRoot })
} catch {
// 忽略保存失败,仍尝试用默认路径连接
}
}
setStartupProgress({
title: '正在连接数据库...',
subtitle: '正在初始化微信数据',
detail: '请稍候',
percent: 8
})
try {
setStartupProgress({
title: '正在连接数据库...',
subtitle: '正在初始化微信数据',
detail: '正在打开 WCDB 数据库',
percent: 15
})
const result = await window.api.initDb(keyToUse)
const success = typeof result === 'boolean' ? result : result.success
if (success) {
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
setStartupProgress({
title: '正在读取缓存...',
subtitle: '正在恢复上次联系人和头像',
detail: '正在读取本地缓存',
percent: 25
})
const hasBootstrapCache = await loadBootstrapCache()
// 持久化手动输入的密钥,供下次启动继续使用
void window.api.saveDbKey(keyToUse).catch(() => undefined)
setStartupProgress({
title: '正在加载账号信息...',
subtitle: '即将进入 WechatExplorer',
detail: '正在读取当前账号信息',
percent: 95
})
void refreshSelfInfo()
setStartupProgress({
title: '加载完成',
subtitle: '正在进入主页面',
detail: '联系人和头像已准备好',
percent: 100
})
setIsAuthenticated(true)
setIsDatabaseConnected(true)
setBootState('login')
window.setTimeout(() => {
setStartupProgress(null)
if (!hasBootstrapCache) void loadContacts({ waitForAvatars: false })
}, 500)
} else {
const error = typeof result === 'boolean' ? '' : result.error
setBootState('login')
setStartupProgress(null)
alert(`Failed to open database.${error ? `\n\n${error}` : '\nCheck your key.'}`)
}
} catch (error) {
console.error(error)
setBootState('login')
setStartupProgress(null)
alert('Error connecting to database')
}
}
const logGroupSnapshot = React.useCallback(
async (contact: Contact | null, reason: string): Promise<GroupSnapshot | null> => {
if (!contact || contact.type !== 'group') return null
try {
const snapshot = (await window.api.getGroupSnapshot(contact.md5)) as GroupSnapshot | null
if (!snapshot) {
return null
}
return snapshot
} catch (error) {
console.warn(`[GroupSnapshot] reason=${reason} name=${contact.m_nsNickName} failed:`, error)
return null
}
},
[]
)
const mergeSyntheticMessages = React.useCallback(
(contact: Contact | null, baseMessages: Message[], roomId?: string): Message[] => {
if (!contact || contact.type !== 'group') return baseMessages
const resolvedRoomId = roomId || currentGroupSnapshotRef.current?.roomId
if (!resolvedRoomId) return baseMessages
const synthetic = syntheticGroupMessagesRef.current[resolvedRoomId] || []
return synthetic.length
? sortMessagesChronologically([...baseMessages, ...synthetic])
: baseMessages
},
[]
)
const applyGroupMemberMeta = React.useCallback(
(contact: Contact | null, baseMessages: Message[]): Message[] => {
if (!contact || contact.type !== 'group') return baseMessages
const memberMap = groupMemberMetaRef.current[contact.md5]
if (!memberMap || memberMap.size === 0) return baseMessages
return baseMessages.map((message) => {
const senderId = String(message.senderId || message.name || '').trim()
if (!senderId) return message
const member = memberMap.get(senderId)
if (!member) return message
const rawNickname = String(member.nickname || '').trim()
const nickname =
rawNickname &&
rawNickname !== senderId &&
!rawNickname.startsWith('wxid_') &&
!/^[a-z]{2,}\d{4,}$/i.test(rawNickname)
? rawNickname
: senderId
return {
...message,
name: nickname,
img: message.img || member.avatar
}
})
},
[]
)
const loadGroupMemberMeta = React.useCallback(
async (contact: Contact | null): Promise<GroupSnapshot | null> => {
if (!contact || contact.type !== 'group') return null
const snapshot = await logGroupSnapshot(contact, 'load-member-meta')
if (!snapshot) return null
currentGroupSnapshotRef.current = snapshot
groupMemberMetaRef.current[contact.md5] = new Map(
snapshot.members.map((member) => [
member.wxid,
{ nickname: member.nickname || member.wxid, avatar: member.avatar || '' }
])
)
return snapshot
},
[logGroupSnapshot]
)
const handleAutoGetDbKey = async (): Promise<void> => {
if (isFetchingDbKey) return
setIsFetchingDbKey(true)
setDbKeyStatus('正在准备获取密钥...')
setDbKeyStatusKind('normal')
setShowMacKeyFaq(false)
try {
const result = await window.api.autoGetDbKey()
if (!result.success || !result.key) {
setShowMacKeyFaq(result.code === 'SCAN_FAILED')
throw new Error(result.error || '获取密钥失败')
}
setDbKey(result.key)
setDbKeyStatus(result.saved ? '密钥已获取并安全保存' : result.warning || '密钥已获取')
setDbKeyStatusKind(result.saved ? 'success' : 'normal')
} catch (error) {
setDbKeyStatus(error instanceof Error ? error.message : String(error))
setDbKeyStatusKind('error')
} finally {
setIsFetchingDbKey(false)
}
}
const handlePasteAndSaveDbKey = async (): Promise<void> => {
setShowMacKeyFaq(false)
const result = await window.api.pasteAndSaveDbKey()
if (result.success && result.key) {
setDbKey(result.key)
setDbKeyStatus('已从剪贴板粘贴并安全保存')
setDbKeyStatusKind('success')
} else {
setDbKeyStatus(result.error || '粘贴并保存失败')
setDbKeyStatusKind('error')
}
}
const handleClearSavedDbKey = async (): Promise<void> => {
setShowMacKeyFaq(false)
const result = await window.api.clearSavedDbKey()
if (!result.success) {
setDbKeyStatus(result.error || '清除密钥失败')
setDbKeyStatusKind('error')
return
}
setDbKey('')
setDbKeyStatus('已清除保存的密钥')
setDbKeyStatusKind('normal')
}
const handleReturnToLogin = (): void => {
setIsAuthenticated(false)
setIsDatabaseConnected(false)
setBootState('login')
setActivePage('archive')
setSettingsCategory('database-key')
setSelectedContact(null)
setMessages([])
setContacts([])
setFilteredContacts([])
setSelfInfo(null)
setIsMessagesLoading(false)
setIsNativeMonitorActive(false)
setReportNotice('')
setDbKeyStatus('已断开当前连接,可重新输入或获取数据库密钥')
setDbKeyStatusKind('normal')
setStartupProgress(null)
}
const getDateRangeParams = (
range: string
): { startTime: number | undefined; endTime: number | undefined } => {
const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000
let startTime: number | undefined
let endTime: number | undefined
switch (range) {
case 'today':
startTime = startOfToday
break
case 'yesterday':
startTime = startOfToday - 86400
endTime = startOfToday - 1 // 昨天结束
break
case '7':
startTime = Math.floor(Date.now() / 1000) - 7 * 86400
break
case '30':
startTime = Math.floor(Date.now() / 1000) - 30 * 86400
break
case 'all':
startTime = undefined
break
default:
startTime = startOfToday
}
return { startTime, endTime }
}
const handleSelectContact = async (contact: Contact): Promise<void> => {
setSelectedContact(contact)
selectedContactMd5Ref.current = contact.md5
currentGroupSnapshotRef.current = null
setIsMessagesLoading(true)
const { startTime, endTime } = getDateRangeParams(dateRange)
const cachedMsgs = await window.api.getCachedMessages(contact.md5, startTime, endTime)
if (selectedContactMd5Ref.current !== contact.md5) return
if (cachedMsgs.length) {
setMessages(
applyGroupMemberMeta(
contact,
mergeSyntheticMessages(contact, cachedMsgs.slice(-VIEW_MESSAGE_LIMIT))
)
)
} else {
setMessages([])
}
await waitForPaint()
try {
const msgs = await window.api.getMessages(contact.md5, startTime, endTime, {
limit: VIEW_MESSAGE_LIMIT
})
if (selectedContactMd5Ref.current !== contact.md5) return
const cachedMessages = applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, msgs))
setMessages(cachedMessages)
if (contact.type === 'group') {
window.setTimeout(() => {
void loadGroupMemberMeta(contact).then((snapshot) => {
if (selectedContactMd5Ref.current !== contact.md5) return
if (!snapshot) return
setMessages((current) =>
applyGroupMemberMeta(
contact,
mergeSyntheticMessages(contact, current, snapshot.roomId)
)
)
})
}, 120)
}
} finally {
if (selectedContactMd5Ref.current === contact.md5) setIsMessagesLoading(false)
}
}
const handleDateRangeChange = (range: string): void => {
setDateRange(range)
if (selectedContact) {
const { startTime, endTime } = getDateRangeParams(range)
window.api
.getCachedMessages(selectedContact.md5, startTime, endTime)
.then((cachedMessages) => {
if (!cachedMessages.length) return
setMessages(
applyGroupMemberMeta(
selectedContact,
mergeSyntheticMessages(selectedContact, cachedMessages.slice(-VIEW_MESSAGE_LIMIT))
)
)
})
setIsMessagesLoading(true)
window.api
.getMessages(selectedContact.md5, startTime, endTime, { limit: VIEW_MESSAGE_LIMIT })
.then((nextMessages) => {
setMessages(
applyGroupMemberMeta(
selectedContact,
mergeSyntheticMessages(selectedContact, nextMessages)
)
)
setIsMessagesLoading(false)
})
.catch((error) => {
console.warn('[Messages] date range load failed:', error)
setIsMessagesLoading(false)
})
}
}
React.useEffect(() => {
if (!isAuthenticated || !selectedContact || !isNativeMonitorActive) return
let disposed = false
let refreshTimer: number | null = null
let refreshInFlight = false
let refreshQueued = false
const contactMd5 = selectedContact.md5
const refreshCurrentConversation = async (): Promise<void> => {
if (refreshInFlight) {
refreshQueued = true
return
}
refreshInFlight = true
try {
const range = getDateRangeParams(dateRange)
const latestMessages = await window.api.getMessages(
contactMd5,
range.startTime,
range.endTime,
{ limit: VIEW_MESSAGE_LIMIT }
)
const nextMessages = applyGroupMemberMeta(
selectedContact,
mergeSyntheticMessages(selectedContact, latestMessages)
)
if (!disposed) {
setMessages((current) =>
areMessagesEquivalent(current, nextMessages) ? current : nextMessages
)
}
} catch (error) {
console.warn('[MessageMonitor] 刷新当前会话失败:', error)
}
refreshInFlight = false
if (refreshQueued && !disposed) {
refreshQueued = false
if (refreshTimer) window.clearTimeout(refreshTimer)
refreshTimer = window.setTimeout(() => {
refreshTimer = null
void refreshCurrentConversation()
}, MESSAGE_MONITOR_DEBOUNCE_MS)
}
}
const unsubscribe = window.api.onWcdbChange(() => {
if (refreshTimer) window.clearTimeout(refreshTimer)
refreshTimer = window.setTimeout(() => {
refreshTimer = null
void refreshCurrentConversation()
}, MESSAGE_MONITOR_DEBOUNCE_MS)
})
return () => {
disposed = true
if (refreshTimer) window.clearTimeout(refreshTimer)
unsubscribe()
}
}, [
dateRange,
isAuthenticated,
isNativeMonitorActive,
selectedContact,
logGroupSnapshot,
applyGroupMemberMeta,
mergeSyntheticMessages
])
const handleSearchContacts = (keyword: string): void => {
if (!keyword) {
setFilteredContacts(contacts)
} else {
const lower = keyword.toLowerCase()
const filtered = contacts.filter(
(c) =>
c.m_nsNickName.toLowerCase().includes(lower) ||
c.m_nsUsrName.toLowerCase().includes(lower)
)
setFilteredContacts(filtered)
}
}
const isGroupContact = (contact: Contact | null): boolean =>
Boolean(contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom'))
const handlePageChange = (page: AppPage): void => {
setActivePage(page)
if (page === 'settings') setSettingsCategory('account-database')
if (page === 'report' && isGroupContact(selectedContact) && !reportSourceContact) {
setReportSourceContact(selectedContact)
}
if (page === 'report') {
setReportWorkspaceView('result')
setSelectedReportId(null)
}
}
const openSettings = (): void => {
setSettingsCategory('account-database')
setActivePage('settings')
}
const openModelSettings = (): void => {
setSettingsCategory('ai-model')
setActivePage('settings')
}
const openReport = (reportId: string): void => {
setSelectedReportId(reportId)
setReportWorkspaceView('result')
setActivePage('report')
}
const handleOpenReportWorkspace = (): void => {
if (!selectedContact) {
setReportNotice('请先选择一个群聊')
return
}
if (!isGroupContact(selectedContact)) {
setReportNotice('AI 群聊日报仅支持群聊')
return
}
setReportNotice('')
setReportSourceContact(selectedContact)
setReportWorkspaceView('configure')
setActivePage('report')
}
const handleSelectReportSource = (contact: Contact): void => {
setReportSourceContact(contact)
if (selectedContact?.md5 !== contact.md5) {
void handleSelectContact(contact)
}
}
React.useEffect(() => {
if (
reportGeneration.phase !== 'success' ||
!reportSourceContact ||
!reportGeneration.generatedImage ||
!reportGeneration.reportPaths
) {
return
}
const recordKey = `${reportGeneration.reportPaths.pngPath}:${reportGeneration.reportPaths.htmlPath}`
if (lastCapturedReportKeyRef.current === recordKey) return
lastCapturedReportKeyRef.current = recordKey
setLatestGeneratedReportId(null)
setIsSavingGeneratedReport(true)
const saveReport = async (): Promise<void> => {
const result = await window.api.saveGeneratedReport({
contactId: reportSourceContact.md5,
contactName: contactDisplayName(reportSourceContact),
contactAvatar: reportSourceContact.avatar || undefined,
dateRange:
summaryDateRange === 'yesterday'
? '昨日'
: summaryDateRange === '7days'
? '近 7 天'
: '今天',
messageCount: reportGeneration.reportMessages.length,
generatedAt: new Date().toISOString(),
generatedImage: reportGeneration.generatedImage || undefined,
htmlPath: reportGeneration.reportPaths?.htmlPath,
pngPath: reportGeneration.reportPaths?.pngPath,
duration: reportGeneration.generationMetadata.durationMs,
modelName: reportGeneration.generationMetadata.modelName || aiModelConfig.model,
tokenUsage: reportGeneration.generationMetadata.tokenUsage,
generationLogs: reportGeneration.generationMetadata.generationLogs
})
if (!result.success || !result.record) {
setIsSavingGeneratedReport(false)
setReportNotice(result.error || '日报保存失败')
return
}
setGeneratedReports((current) => [
result.record as GeneratedReportRecord,
...current.filter((report) => report.id !== result.record?.id)
])
setLatestGeneratedReportId(result.record.id)
setIsSavingGeneratedReport(false)
openReport(result.record.id)
}
void saveReport()
}, [
aiModelConfig.model,
reportGeneration.generatedImage,
reportGeneration.generationMetadata,
reportGeneration.phase,
reportGeneration.reportMessages.length,
reportGeneration.reportPaths,
reportSourceContact,
summaryDateRange
])
const selectedReport = generatedReports.find((report) => report.id === selectedReportId) || null
const openReportResult = (): void => {
if (isSavingGeneratedReport) {
setReportNotice('日报正在保存,请稍候')
return
}
const targetReportId = latestGeneratedReportId || selectedReportId
if (targetReportId) {
openReport(targetReportId)
return
}
setReportWorkspaceView('result')
}
const openReportConfigure = (): void => {
setReportWorkspaceView('configure')
}
const handleRegenerateReport = (): void => {
if (selectedReport) {
const source = contacts.find((contact) => contact.md5 === selectedReport.contactId)
if (source) setReportSourceContact(source)
}
setReportWorkspaceView('configure')
}
const handleCopyReportImage = async (
report: GeneratedReportRecord
): Promise<{ success: boolean; error?: string }> => {
if (!report.generatedImage) return { success: false, error: '没有可复制的日报图片' }
return window.api.copyImage(report.generatedImage)
}
const handleRevealReport = async (
report: GeneratedReportRecord
): Promise<{ success: boolean; error?: string }> => {
const filePath = report.pngPath || report.htmlPath
if (!filePath) return { success: false, error: '当前报告缺少文件路径' }
return window.api.revealGroupReport(filePath)
}
const handleDeleteReport = async (
reportId: string
): Promise<{ success: boolean; error?: string }> => {
const result = await window.api.deleteGeneratedReport(reportId)
if (!result.success) return { success: false, error: result.error || '删除日报失败' }
setGeneratedReports((current) => current.filter((report) => report.id !== reportId))
if (selectedReportId === reportId) {
setSelectedReportId(null)
}
if (latestGeneratedReportId === reportId) setLatestGeneratedReportId(null)
return { success: true }
}
const renderPlaceholderPage = (
page: Exclude<AppPage, 'archive' | 'report'>
): React.ReactElement => {
const labels: Record<Exclude<AppPage, 'archive' | 'report'>, string> = {
search: '检索',
export: '导出',
api: 'API',
settings: '设置'
}
return (
<div className="app-page-placeholder">
<div className="app-page-placeholder-eyebrow">WechatExplorer</div>
<h2>{labels[page]}</h2>
<p>这个工作区会在后续 UI 重构阶段接入真实功能。</p>
</div>
)
}
const renderArchiveWorkspace = (): React.ReactElement => (
<div className="app-container">
<Sidebar
contacts={filteredContacts}
selectedContact={selectedContact}
onSelectContact={handleSelectContact}
onSearch={handleSearchContacts}
onContentFilter={setContentFilter}
width={sidebarWidth}
dateRange={dateRange}
onDateRangeChange={handleDateRangeChange}
selfInfo={selfInfo}
dbReady={isDatabaseConnected}
onOpenSettings={openSettings}
/>
<div className="resizer" onMouseDown={startResizing} />
<ChatWindow
key={selectedContact?.md5}
contact={selectedContact}
messages={messages}
isLoadingMessages={isMessagesLoading}
contentFilter={contentFilter}
dateRange={dateRange}
onContentFilterChange={setContentFilter}
onRefresh={() => selectedContact && handleSelectContact(selectedContact)}
onRefreshData={loadContacts}
onCreateGroupReport={handleOpenReportWorkspace}
isAiLoading={reportGeneration.isGenerating}
/>
</div>
)
const renderReportWorkspace = (): React.ReactElement =>
reportWorkspaceView === 'result' ? (
<div className="report-center-page">
<ReportHistorySidebar
reports={generatedReports}
selectedReportId={selectedReportId}
selfInfo={selfInfo}
dbReady={isDatabaseConnected}
onSelectReport={openReport}
onCreateReport={openReportConfigure}
onDeleteReport={handleDeleteReport}
onOpenSettings={openSettings}
/>
<ReportViewer
report={selectedReport}
hasReports={generatedReports.length > 0}
onBackToConfigure={openReportConfigure}
onRegenerate={handleRegenerateReport}
onCopyImage={handleCopyReportImage}
onReveal={handleRevealReport}
/>
<ReportInfoPanel report={selectedReport} onReveal={handleRevealReport} />
</div>
) : (
<div className="report-page">
<ReportSourceSidebar
contacts={contacts}
selectedContact={reportSourceContact}
selfInfo={selfInfo}
dbReady={isDatabaseConnected}
onSelectContact={handleSelectReportSource}
onOpenSettings={openSettings}
/>
<AiReportWorkspace
sourceContact={reportSourceContact}
summaryDateRange={summaryDateRange}
summaryMessageTypes={summaryMessageTypes}
modelConfig={aiModelConfig}
rangeMessageCount={reportGeneration.rangeMessages.length}
reportMessageCount={reportGeneration.reportMessages.length}
messageTypeCounts={reportGeneration.messageTypeCounts}
rangeState={reportGeneration.rangeState}
phase={reportGeneration.phase}
error={reportGeneration.error}
generatedImage={reportGeneration.generatedImage}
reportPaths={reportGeneration.reportPaths}
isGenerating={reportGeneration.isGenerating}
onSummaryDateRangeChange={setSummaryDateRange}
onSummaryMessageTypesChange={setSummaryMessageTypes}
onOpenModelSettings={openModelSettings}
onGenerate={() => {
reportGeneration.resetGenerationStatus()
void reportGeneration.generate()
}}
onCloseResult={reportGeneration.closeResult}
onCopyImage={reportGeneration.copyImage}
onRevealReport={reportGeneration.revealReport}
onViewResult={openReportResult}
hasReportResult={generatedReports.length > 0}
templateId={reportGeneration.templateId}
onTemplateIdChange={reportGeneration.setTemplateId}
memberNamePreference={reportGeneration.memberNamePreference}
onMemberNamePreferenceChange={reportGeneration.setMemberNamePreference}
reportTimeoutSeconds={reportGeneration.reportTimeoutSeconds}
onReportTimeoutSecondsChange={reportGeneration.setReportTimeoutSeconds}
/>
<ReportTaskStatusPanel
phase={reportGeneration.phase}
error={reportGeneration.error}
onRetry={() => {
reportGeneration.resetGenerationStatus()
void reportGeneration.retry()
}}
/>
</div>
)
const renderCurrentWorkspace = (): React.ReactElement => {
switch (activePage) {
case 'archive':
return renderArchiveWorkspace()
case 'report':
return renderReportWorkspace()
case 'api':
return (
<ApiWorkspace
selectedContact={selectedContact}
dbReady={isDatabaseConnected}
onOpenSettings={openSettings}
/>
)
case 'settings':
return (
<SettingsWorkspace
selectedCategory={settingsCategory}
onCategoryChange={setSettingsCategory}
selfInfo={selfInfo}
dbReady={isDatabaseConnected}
dbKey={dbKey}
onDbKeyChange={setDbKey}
onDatabaseConnectionChange={setIsDatabaseConnected}
onSelfInfoChange={setSelfInfo}
onContactsChange={setContacts}
onFilteredContactsChange={setFilteredContacts}
onReturnToLogin={handleReturnToLogin}
onAIRuntimeChange={(config: AIRuntimeModelConfig) => setAiModelConfig(config)}
onNotice={setReportNotice}
onOpenSettings={openSettings}
/>
)
case 'search':
case 'export':
return renderPlaceholderPage(activePage)
}
}
const [sidebarWidth, setSidebarWidth] = useState(300)
const [isResizing, setIsResizing] = useState(false)
const sidebarResizeStartRef = React.useRef({ x: 0, width: 300 })
const startResizing = React.useCallback(
(mouseDownEvent: React.MouseEvent<HTMLDivElement>) => {
sidebarResizeStartRef.current = {
x: mouseDownEvent.clientX,
width: sidebarWidth
}
setIsResizing(true)
},
[sidebarWidth]
)
const stopResizing = React.useCallback(() => {
setIsResizing(false)
}, [])
const resize = React.useCallback(
(mouseMoveEvent: MouseEvent) => {
if (isResizing) {
const { x, width } = sidebarResizeStartRef.current
const nextWidth = width + mouseMoveEvent.clientX - x
setSidebarWidth(Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, nextWidth)))
}
},
[isResizing]
)
React.useEffect(() => {
window.addEventListener('mousemove', resize)
window.addEventListener('mouseup', stopResizing)
return () => {
window.removeEventListener('mousemove', resize)
window.removeEventListener('mouseup', stopResizing)
}
}, [resize, stopResizing])
if (!isAuthenticated && bootState !== 'login') {
const title =
startupProgress?.title ||
(bootState === 'connecting' ? '正在自动连接数据库...' : '正在准备...')
const subtitle =
startupProgress?.subtitle ||
(bootState === 'connecting'
? autoConnectSource === 'env'
? '检测到环境变量中的密钥'
: '使用上次安全保存的密钥'
: 'WechatExplorer')
return (
<div className="boot-splash">
<div className="boot-splash-spinner" aria-hidden />
<div className="boot-splash-title">{title}</div>
<div className="boot-splash-subtitle">{subtitle}</div>
{startupProgress?.detail && (
<div className="boot-splash-detail">{startupProgress.detail}</div>
)}
{typeof startupProgress?.percent === 'number' && (
<div className="boot-splash-progress" aria-label="加载进度">
<div
className="boot-splash-progress-bar"
style={{ width: `${Math.max(0, Math.min(100, startupProgress.percent))}%` }}
/>
</div>
)}
</div>
)
}
if (!isAuthenticated) {
return (
<div className="login-modal">
<div className="login-box">
<h2>Enter WeChat DB Key</h2>
<div className="login-input-wrapper">
<input
type={showDbKey ? 'text' : 'password'}
className="login-input"
value={dbKey}
onChange={(e) => setDbKey(e.target.value)}
placeholder="Key (e.g. 0x...)"
/>
<button
type="button"
className="login-input-toggle"
onClick={() => setShowDbKey(!showDbKey)}
title={showDbKey ? '隐藏密钥' : '显示密钥'}
>
<EyeIcon hidden={showDbKey} />
</button>
</div>
{window.electron.process.platform === 'win32' && (
<div className="login-input-wrapper">
<input
type="text"
className="login-input"
value={dbRootInput}
onChange={(e) => setDbRootInput(e.target.value)}
placeholder="微信聊天文件路径 (如 D:\\Tencent\\WeChat\\xwechat_files)"
spellCheck={false}
autoComplete="off"
/>
</div>
)}
<button
className="login-btn login-btn-secondary"
onClick={handleAutoGetDbKey}
disabled={isFetchingDbKey}
>
{isFetchingDbKey ? '正在获取...' : '自动获取密钥'}
</button>
<button className="login-btn login-btn-secondary" onClick={handlePasteAndSaveDbKey}>
粘贴并安全保存
</button>
<button className="login-btn" onClick={() => handleLogin()}>
Connect
</button>
<button className="login-clear-btn" onClick={handleClearSavedDbKey}>
清除已保存密钥
</button>
{dbKeyStatus && (
<div className={`login-key-status ${dbKeyStatusKind}`}>{dbKeyStatus}</div>
)}
{showMacKeyFaq && (
<a
className="login-key-help-link"
href={MAC_KEY_FAQ_URL}
target="_blank"
rel="noreferrer"
>
查看 macOS 获取密钥排障指引
</a>
)}
</div>
</div>
)
}
return (
<AppShell
activePage={activePage}
selfInfo={selfInfo}
dbReady={isDatabaseConnected}
onPageChange={handlePageChange}
onOpenSettings={openSettings}
>
{reportNotice && <div className="app-toast">{reportNotice}</div>}
{renderCurrentWorkspace()}
</AppShell>
)
}
export default App