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
+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 = {