diff --git a/electron/main.ts b/electron/main.ts index 6239e67..9c932ba 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -3561,6 +3561,10 @@ function registerIpcHandlers() { return analyticsService.getTimeDistribution() }) + ipcMain.handle('analytics:getSelfSentDailyDistribution', async (_, beginTimestamp?: number, endTimestamp?: number, force?: boolean) => { + return analyticsService.getSelfSentDailyDistribution(beginTimestamp, endTimestamp, force) + }) + ipcMain.handle('analytics:getExcludedUsernames', async () => { return analyticsService.getExcludedUsernames() }) diff --git a/electron/preload.ts b/electron/preload.ts index 5f195b4..98e97f3 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -399,6 +399,8 @@ contextBridge.exposeInMainWorld('electronAPI', { getContactRankings: (limit?: number, beginTimestamp?: number, endTimestamp?: number) => ipcRenderer.invoke('analytics:getContactRankings', limit, beginTimestamp, endTimestamp), getTimeDistribution: () => ipcRenderer.invoke('analytics:getTimeDistribution'), + getSelfSentDailyDistribution: (beginTimestamp?: number, endTimestamp?: number, force?: boolean) => + ipcRenderer.invoke('analytics:getSelfSentDailyDistribution', beginTimestamp, endTimestamp, force), getExcludedUsernames: () => ipcRenderer.invoke('analytics:getExcludedUsernames'), setExcludedUsernames: (usernames: string[]) => ipcRenderer.invoke('analytics:setExcludedUsernames', usernames), getExcludeCandidates: () => ipcRenderer.invoke('analytics:getExcludeCandidates'), diff --git a/electron/services/analyticsService.ts b/electron/services/analyticsService.ts index d50133e..9cffe56 100644 --- a/electron/services/analyticsService.ts +++ b/electron/services/analyticsService.ts @@ -27,6 +27,16 @@ export interface TimeDistribution { monthlyDistribution: Record } +export interface SelfSentDailyDistribution { + unit: 'day' + dailyDistribution: Record + totalMessages: number + firstMessageTime: number | null + lastMessageTime: number | null + beginTimestamp: number + endTimestamp: number +} + export interface ContactRanking { username: string displayName: string @@ -42,6 +52,7 @@ class AnalyticsService { private configService: ConfigService private fallbackAggregateCache: { key: string; data: any; updatedAt: number } | null = null private aggregateCache: { key: string; data: any; updatedAt: number } | null = null + private selfSentDailyCache: { key: string; data: SelfSentDailyDistribution; updatedAt: number } | null = null private aggregatePromise: { key: string; promise: Promise<{ success: boolean; data?: any; source?: string; error?: string }> } | null = null constructor() { @@ -190,15 +201,12 @@ class AnalyticsService { sessionId: string, onRow: (row: Record) => void, beginTimestamp = 0, - endTimestamp = 0 + endTimestamp = 0, + lite = false ): Promise { - const cursorResult = await wcdbService.openMessageCursor( - sessionId, - 500, - true, - beginTimestamp, - endTimestamp - ) + const cursorResult = lite + ? await wcdbService.openMessageCursorLite(sessionId, 500, true, beginTimestamp, endTimestamp) + : await wcdbService.openMessageCursor(sessionId, 500, true, beginTimestamp, endTimestamp) if (!cursorResult.success || !cursorResult.cursor) return try { @@ -223,6 +231,76 @@ class AnalyticsService { } } + private getRowCreateTime(row: Record): number { + const raw = row.create_time ?? row.createTime ?? row.create_time_ms ?? '0' + const parsed = parseInt(String(raw), 10) + if (!Number.isFinite(parsed) || parsed <= 0) return 0 + return parsed > 1e12 ? Math.floor(parsed / 1000) : parsed + } + + private isRowSentByMe(row: Record, cleanedWxid: string): boolean { + const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend + const normalized = String(isSendRaw).trim().toLowerCase() + let isSend = isSendRaw === 1 || isSendRaw === true || normalized === '1' || normalized === 'true' + + if (isSendRaw === undefined || isSendRaw === null) { + const senderUsername = row.sender_username || row.senderUsername || row.sender + if (senderUsername && cleanedWxid) { + const senderLower = String(senderUsername).toLowerCase() + const myWxidLower = cleanedWxid.toLowerCase() + isSend = senderLower === myWxidLower || senderLower.startsWith(`${myWxidLower}_`) + } + } + + return isSend + } + + private formatDayKey(timestamp: number): string { + const date = new Date(timestamp * 1000) + 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}` + } + + private sortDailyDistribution(daily: Record): Record { + const sorted: Record = {} + for (const key of Object.keys(daily).sort()) { + sorted[key] = daily[key] + } + return sorted + } + + private completeDailyDistribution( + daily: Record, + firstTimestamp: number, + lastTimestamp: number + ): Record { + if (!firstTimestamp || !lastTimestamp || lastTimestamp < firstTimestamp) { + return this.sortDailyDistribution(daily) + } + + const start = new Date(firstTimestamp * 1000) + const end = new Date(lastTimestamp * 1000) + start.setHours(0, 0, 0, 0) + end.setHours(0, 0, 0, 0) + + const roughDays = Math.floor((end.getTime() - start.getTime()) / 86400000) + 1 + if (roughDays <= 0 || roughDays > 5000) { + return this.sortDailyDistribution(daily) + } + + const completed: Record = {} + const cursor = new Date(start) + while (cursor.getTime() <= end.getTime()) { + const key = `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}-${String(cursor.getDate()).padStart(2, '0')}` + completed[key] = daily[key] || 0 + cursor.setDate(cursor.getDate() + 1) + } + + return completed + } + private setProgress(window: any, status: string, progress: number) { if (window && !window.isDestroyed()) { window.webContents.send('analytics:progress', { status, progress }) @@ -251,6 +329,7 @@ class AnalyticsService { hourly: {} as Record, weekday: {} as Record, daily: {} as Record, + sentDaily: {} as Record, monthly: {} as Record, sessions: {} as Record, idMap: {} @@ -259,27 +338,13 @@ class AnalyticsService { for (const sessionId of sessionIds) { const sessionStat = { total: 0, sent: 0, received: 0, lastTime: 0 } await this.iterateSessionMessages(sessionId, (row) => { - const createTime = parseInt(row.create_time || row.createTime || row.create_time_ms || '0', 10) + const createTime = this.getRowCreateTime(row) if (!createTime) return if (beginTimestamp > 0 && createTime < beginTimestamp) return if (endTimestamp > 0 && createTime > endTimestamp) return const localType = parseInt(row.local_type || row.type || '1', 10) - const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend - let isSend = String(isSendRaw) === '1' || isSendRaw === 1 || isSendRaw === true - - // 如果底层没有提供 is_send,则根据发送者用户名推断 - const senderUsername = row.sender_username || row.senderUsername || row.sender - if (isSendRaw === undefined || isSendRaw === null) { - if (senderUsername && (cleanedWxid)) { - const senderLower = String(senderUsername).toLowerCase() - const myWxidLower = cleanedWxid.toLowerCase() - isSend = ( - senderLower === myWxidLower || - senderLower.startsWith(myWxidLower + '_') - ) - } - } + const isSend = this.isRowSentByMe(row, cleanedWxid) aggregate.total += 1 sessionStat.total += 1 @@ -314,6 +379,9 @@ class AnalyticsService { aggregate.weekday[weekday] = (aggregate.weekday[weekday] || 0) + 1 aggregate.monthly[monthKey] = (aggregate.monthly[monthKey] || 0) + 1 aggregate.daily[dayKey] = (aggregate.daily[dayKey] || 0) + 1 + if (isSend) { + aggregate.sentDaily[dayKey] = (aggregate.sentDaily[dayKey] || 0) + 1 + } }, beginTimestamp, endTimestamp) if (sessionStat.total > 0) { @@ -324,6 +392,49 @@ class AnalyticsService { return aggregate } + private async computeSelfSentDailyDistribution( + sessionIds: string[], + cleanedWxid: string, + beginTimestamp = 0, + endTimestamp = 0 + ): Promise { + const dailyDistribution: Record = {} + let totalMessages = 0 + let firstMessageTime = 0 + let lastMessageTime = 0 + + for (const sessionId of sessionIds) { + await this.iterateSessionMessages(sessionId, (row) => { + const createTime = this.getRowCreateTime(row) + if (!createTime) return + if (beginTimestamp > 0 && createTime < beginTimestamp) return + if (endTimestamp > 0 && createTime > endTimestamp) return + if (!this.isRowSentByMe(row, cleanedWxid)) return + + const dayKey = this.formatDayKey(createTime) + dailyDistribution[dayKey] = (dailyDistribution[dayKey] || 0) + 1 + totalMessages += 1 + + if (firstMessageTime === 0 || createTime < firstMessageTime) { + firstMessageTime = createTime + } + if (createTime > lastMessageTime) { + lastMessageTime = createTime + } + }, beginTimestamp, endTimestamp, true) + } + + return { + unit: 'day', + dailyDistribution: this.completeDailyDistribution(dailyDistribution, firstMessageTime, lastMessageTime), + totalMessages, + firstMessageTime: firstMessageTime || null, + lastMessageTime: lastMessageTime || null, + beginTimestamp, + endTimestamp + } + } + private async getAggregateWithFallback( sessionIds: string[], beginTimestamp = 0, @@ -668,9 +779,47 @@ class AnalyticsService { } } + async getSelfSentDailyDistribution( + beginTimestamp: number = 0, + endTimestamp: number = 0, + force = false + ): Promise<{ success: boolean; data?: SelfSentDailyDistribution; error?: string }> { + try { + const conn = await this.ensureConnected() + if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } + + const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid) + if (sessionInfo.usernames.length === 0) { + return { success: false, error: '未找到消息会话' } + } + + const cacheKey = `self-sent-daily-${this.buildAggregateCacheKey(sessionInfo.usernames, beginTimestamp, endTimestamp)}` + if (force) this.selfSentDailyCache = null + + if (!force && this.selfSentDailyCache && this.selfSentDailyCache.key === cacheKey) { + if (Date.now() - this.selfSentDailyCache.updatedAt < 5 * 60 * 1000) { + return { success: true, data: this.selfSentDailyCache.data } + } + } + + const data = await this.computeSelfSentDailyDistribution( + sessionInfo.usernames, + conn.cleanedWxid, + beginTimestamp, + endTimestamp + ) + this.selfSentDailyCache = { key: cacheKey, data, updatedAt: Date.now() } + + return { success: true, data } + } catch (e) { + return { success: false, error: String(e) } + } + } + async clearCache(): Promise<{ success: boolean; error?: string }> { this.aggregateCache = null this.fallbackAggregateCache = null + this.selfSentDailyCache = null this.aggregatePromise = null try { await rm(this.getCacheFilePath(), { force: true }) diff --git a/electron/services/insightService.ts b/electron/services/insightService.ts index d132c50..c23b093 100644 --- a/electron/services/insightService.ts +++ b/electron/services/insightService.ts @@ -1,4 +1,4 @@ -/** +/** * insightService.ts * * AI 见解后台服务: @@ -47,6 +47,18 @@ const API_MAX_TOKENS_MIN = 1 const API_MAX_TOKENS_MAX = 2_000_000 const API_TEMPERATURE = 0.7 const INSIGHT_NOTIFICATION_AVATAR_URL = './assets/insight/AI_Insight.png' +const MIMO_FOOTPRINT_MIN_TOKENS = 4096 +const FOOTPRINT_API_TEMPERATURE = 0.2 + +const DEFAULT_FOOTPRINT_SYSTEM_PROMPT = `你是“我的微信足迹”模块的总结器,只能根据用户提供的统计数据生成最终复盘文案。 +硬性输出规则: +1. 只输出最终总结正文,不输出思考过程、步骤、标题、列表、JSON、Markdown、代码块、引号或字段名。 +2. 输出 2 句中文,总长度 60-160 字,最多 180 字。 +3. 第 1 句概括联络活跃度、回复情况或 @我情况;第 2 句给出一个当天/当前范围内可执行的沟通建议。 +4. 必须引用至少 2 个输入数字,例如人数、回复率、@我次数或群聊数。 +5. 数据为 0 时如实说明,不臆测具体聊天内容、关系、情绪、诊断或原因。 +6. 禁止出现“首先”“其次”“根据”“综上”“作为AI”“我认为”“以下是”等过程性表达。 +输出格式:直接输出两句自然中文。` /** 沉默天数阈值默认值 */ const DEFAULT_SILENCE_DAYS = 3 @@ -96,6 +108,13 @@ interface SessionInsightTriggerResult { type InsightFilterMode = 'whitelist' | 'blacklist' +interface CallApiOptions { + temperature?: number + disableThinking?: boolean + useMaxCompletionTokens?: boolean + responseFormatJson?: boolean +} + class ApiRequestError extends Error { statusCode?: number responseBody?: string @@ -188,6 +207,54 @@ function normalizeSessionIdList(value: unknown): string[] { return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean))) } +function isMimoModel(apiBaseUrl: string, model: string): boolean { + const target = `${apiBaseUrl} ${model}`.toLowerCase() + return target.includes('mimo') || target.includes('xiaomi') +} + +function buildFootprintSystemPrompt(customPrompt: string): string { + const custom = String(customPrompt || '').trim() + if (!custom || custom === DEFAULT_FOOTPRINT_SYSTEM_PROMPT) { + return DEFAULT_FOOTPRINT_SYSTEM_PROMPT + } + return `${DEFAULT_FOOTPRINT_SYSTEM_PROMPT} + +用户自定义补充要求如下,只能在不违反上述硬性输出规则时执行: +${custom}` +} + +function normalizeFootprintInsight(text: string): string { + let normalized = String(text || '').trim() + if (!normalized) return '' + + if (normalized.startsWith('{') && normalized.endsWith('}')) { + try { + const parsed = JSON.parse(normalized) + const value = parsed?.summary || parsed?.insight || parsed?.content || parsed?.text + if (typeof value === 'string' && value.trim()) { + normalized = value.trim() + } + } catch { } + } + + normalized = normalized + .replace(/^```(?:text|markdown|md|json)?/i, '') + .replace(/```$/i, '') + .replace(/^(足迹复盘|AI足迹总结|AI 足迹总结|总结|建议)[::]\s*/i, '') + .replace(/^\s*[-*•]\s*/gm, '') + .replace(/\s*\n+\s*/g, ' ') + .replace(/\s{2,}/g, ' ') + .trim() + + if (normalized.length > 180) { + const sliced = normalized.slice(0, 180) + const lastStop = Math.max(sliced.lastIndexOf('。'), sliced.lastIndexOf('!'), sliced.lastIndexOf('?')) + normalized = lastStop >= 60 ? sliced.slice(0, lastStop + 1) : `${sliced.replace(/[,,;;、\s]+$/g, '')}。` + } + + return normalized +} + function clampText(value: unknown, maxLength: number): string { const text = String(value || '').replace(/\s+/g, ' ').trim() if (text.length <= maxLength) return text @@ -245,7 +312,7 @@ function callApi( messages: Array<{ role: string; content: string }>, timeoutMs: number = API_TIMEOUT_MS, maxTokens: number = API_MAX_TOKENS_DEFAULT, - options?: { responseFormatJson?: boolean } + options: CallApiOptions = {} ): Promise { return new Promise((resolve, reject) => { const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions') @@ -257,13 +324,22 @@ function callApi( return } + const normalizedMaxTokens = normalizeApiMaxTokens(maxTokens) const payload: Record = { model, messages, - max_tokens: normalizeApiMaxTokens(maxTokens), - temperature: API_TEMPERATURE, + temperature: options.temperature ?? API_TEMPERATURE, stream: false } + if (options.useMaxCompletionTokens) { + payload.max_completion_tokens = normalizedMaxTokens + } else { + payload.max_tokens = normalizedMaxTokens + } + if (options.disableThinking) { + payload.thinking = { type: 'disabled' } + payload.enable_thinking = false + } if (options?.responseFormatJson) { payload.response_format = { type: 'json_object' } } @@ -297,7 +373,13 @@ function callApi( if (typeof content === 'string' && content.trim()) { resolve(content.trim()) } else { - reject(new Error(`API 返回格式异常: ${data.slice(0, 200)}`)) + const finishReason = parsed?.choices?.[0]?.finish_reason + const reasoningContent = parsed?.choices?.[0]?.message?.reasoning_content + if (typeof reasoningContent === 'string' && reasoningContent.trim()) { + reject(new Error(`API 仅返回推理内容未返回正文${finishReason ? `(finish_reason=${finishReason})` : ''},请增大最大输出 Token 或关闭思考模式`)) + return + } + reject(new Error(`API 返回格式异常${finishReason ? `(finish_reason=${finishReason})` : ''}: ${data.slice(0, 200)}`)) } } catch (e) { reject(new Error(`JSON 解析失败: ${data.slice(0, 200)}`)) @@ -652,6 +734,7 @@ class InsightService { const rangeLabel = String(params?.rangeLabel || '').trim() || '当前范围' const privateSegments = Array.isArray(params?.privateSegments) ? params.privateSegments.slice(0, 6) : [] const mentionGroups = Array.isArray(params?.mentionGroups) ? params.mentionGroups.slice(0, 6) : [] + const mimoMode = isMimoModel(apiBaseUrl, model) const topPrivateText = privateSegments.length > 0 ? privateSegments @@ -675,20 +758,31 @@ class InsightService { .join('\n') : '无' - const defaultSystemPrompt = `你是用户的聊天足迹教练,负责基于统计数据给出一段简明复盘。 -要求: -1. 输出 2-3 句,总长度不超过 180 字。 -2. 必须包含:总体观察 + 一个可执行建议。 -3. 语气务实,不夸张,不使用 Markdown。` const customPrompt = String(this.config.get('aiFootprintSystemPrompt') || '').trim() - const systemPrompt = customPrompt || defaultSystemPrompt + const systemPrompt = buildFootprintSystemPrompt(customPrompt) - const userPromptBase = `统计范围:${rangeLabel} -有聊天的人数:${Number(summary.private_inbound_people) || 0} -我有回复的人数:${Number(summary.private_outbound_people) || 0} -回复率:${(((Number(summary.private_reply_rate) || 0) * 100)).toFixed(1)}% -@我次数:${Number(summary.mention_count) || 0} -涉及群聊:${Number(summary.mention_group_count) || 0} + const inboundPeople = Number(summary.private_inbound_people) || 0 + const repliedPeople = Number(summary.private_replied_people) || 0 + const outboundPeople = Number(summary.private_outbound_people) || 0 + const replyRate = (((Number(summary.private_reply_rate) || 0) * 100)).toFixed(1) + const mentionCount = Number(summary.mention_count) || 0 + const mentionGroupCount = Number(summary.mention_group_count) || 0 + + const userPromptBase = `任务:基于下面的“我的微信足迹”统计生成最终总结正文。 + +输出要求再强调一次: +- 只输出 2 句中文自然语言,不要输出分析过程。 +- 不要输出 JSON / Markdown / 列表 / 标题 / 代码块。 +- 第 1 句做总体观察,第 2 句给一个可执行建议。 +- 必须引用至少 2 个统计数字。 + +统计范围:${rangeLabel} +有聊天的人数:${inboundPeople} +我有回复的人数:${outboundPeople} +实际回复了其中:${repliedPeople} +回复率:${replyRate}% +@我次数:${mentionCount} +涉及群聊:${mentionGroupCount} 私聊重点: ${topPrivateText} @@ -696,7 +790,7 @@ ${topPrivateText} 群聊@我重点: ${topMentionText} -请给出足迹复盘(2-3句,含建议):` +现在直接输出最终总结正文:` const userPrompt = appendPromptCurrentTime(userPromptBase) try { @@ -709,9 +803,14 @@ ${topMentionText} { role: 'user', content: userPrompt } ], 25_000, - maxTokens + mimoMode ? Math.max(maxTokens, MIMO_FOOTPRINT_MIN_TOKENS) : maxTokens, + { + temperature: FOOTPRINT_API_TEMPERATURE, + disableThinking: mimoMode, + useMaxCompletionTokens: mimoMode + } ) - const insight = result.trim() + const insight = normalizeFootprintInsight(result) if (!insight) return { success: false, message: '模型返回为空' } return { success: true, message: '生成成功', insight } } catch (error) { @@ -1671,5 +1770,3 @@ ${afterText} } export const insightService = new InsightService() - - diff --git a/src/pages/AnalyticsPage.scss b/src/pages/AnalyticsPage.scss index ae19cf4..e14cba9 100644 --- a/src/pages/AnalyticsPage.scss +++ b/src/pages/AnalyticsPage.scss @@ -184,6 +184,88 @@ color: var(--text-primary); margin: 0 0 12px; } + + .chart-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 0 0 12px; + + h3 { + margin: 0; + } + + span { + font-size: 12px; + color: var(--text-tertiary); + white-space: nowrap; + } + } + + .chart-note { + margin: -4px 0 10px; + font-size: 12px; + line-height: 1.6; + color: var(--text-tertiary); + } + + &.self-sent-ratio-card { + position: relative; + overflow: hidden; + background: + linear-gradient(180deg, rgba(var(--primary-rgb), 0.045), transparent 34%), + var(--card-bg); + + &::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + border-radius: inherit; + box-shadow: inset 0 1px 0 rgba(var(--primary-rgb), 0.08); + } + + .chart-title-row { + align-items: flex-start; + + h3 { + display: flex; + align-items: center; + gap: 8px; + + &::before { + content: ''; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--primary); + box-shadow: 0 0 0 4px var(--primary-light); + flex-shrink: 0; + } + } + + span { + padding: 4px 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--bg-primary) 72%, var(--primary-light)); + border: 1px solid rgba(var(--primary-rgb), 0.14); + color: var(--text-secondary); + line-height: 1.4; + } + } + + .chart-note { + display: inline-flex; + max-width: 100%; + margin: -2px 0 12px; + padding: 8px 10px; + border-radius: 10px; + background: color-mix(in srgb, var(--bg-primary) 82%, var(--card-bg)); + border: 1px solid var(--border-color); + color: var(--text-secondary); + } + } } // Rankings diff --git a/src/pages/AnalyticsPage.tsx b/src/pages/AnalyticsPage.tsx index a689089..acb5fb4 100644 --- a/src/pages/AnalyticsPage.tsx +++ b/src/pages/AnalyticsPage.tsx @@ -36,8 +36,20 @@ function AnalyticsPage() { const [excludedUsernames, setExcludedUsernames] = useState>(new Set()) const [draftExcluded, setDraftExcluded] = useState>(new Set()) - const themeMode = useThemeStore((state) => state.themeMode) - const { statistics, rankings, timeDistribution, isLoaded, setStatistics, setRankings, setTimeDistribution, markLoaded, clearCache } = useAnalyticsStore() + const chartThemeSignature = useThemeStore((state) => `${state.currentTheme}-${state.themeMode}`) + const { + statistics, + rankings, + timeDistribution, + selfSentDailyDistribution, + isLoaded, + setStatistics, + setRankings, + setTimeDistribution, + setSelfSentDailyDistribution, + markLoaded, + clearCache + } = useAnalyticsStore() const loadExcludedUsernames = useCallback(async () => { try { @@ -54,7 +66,14 @@ function AnalyticsPage() { }, []) const loadData = useCallback(async (forceRefresh = false) => { - if (isLoaded && !forceRefresh) return + const currentAnalyticsState = useAnalyticsStore.getState() + if ( + currentAnalyticsState.isLoaded && + !forceRefresh && + currentAnalyticsState.statistics && + currentAnalyticsState.timeDistribution && + currentAnalyticsState.selfSentDailyDistribution + ) return const taskId = registerBackgroundTask({ sourcePage: 'analytics', title: forceRefresh ? '刷新分析看板' : '加载分析看板', @@ -128,6 +147,22 @@ function AnalyticsPage() { if (timeResult.success && timeResult.data) { setTimeDistribution(timeResult.data) } + setLoadingStatus('正在统计每日发送分布...') + updateBackgroundTask(taskId, { + detail: '正在统计每日发送分布', + progressText: '每日发送' + }) + const selfSentDailyResult = await window.electronAPI.analytics.getSelfSentDailyDistribution(0, 0, forceRefresh) + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,每日发送分布结果未继续写入' + }) + setIsLoading(false) + return + } + if (selfSentDailyResult.success && selfSentDailyResult.data) { + setSelfSentDailyDistribution(selfSentDailyResult.data) + } markLoaded() finishBackgroundTask(taskId, 'completed', { detail: '分析看板数据加载完成', @@ -142,7 +177,7 @@ function AnalyticsPage() { setIsLoading(false) if (removeListener) removeListener() } - }, [isLoaded, markLoaded, setRankings, setStatistics, setTimeDistribution]) + }, [markLoaded, setRankings, setSelfSentDailyDistribution, setStatistics, setTimeDistribution]) const location = useLocation() @@ -276,17 +311,42 @@ function AnalyticsPage() { return num.toLocaleString() } - const getChartLabelColors = () => { + const getChartTheme = () => { if (typeof window === 'undefined') { - return { text: '#333333', line: '#999999' } + return { + text: '#333333', + secondaryText: '#666666', + mutedText: '#999999', + line: '#e5e5e5', + surface: '#ffffff', + border: '#e5e5e5', + primary: '#10a37f', + primaryLight: 'rgba(16, 163, 127, 0.1)', + danger: '#ef4444', + warning: '#f59e0b', + success: '#10a37f', + info: '#3b82f6' + } } const styles = getComputedStyle(document.documentElement) - const text = styles.getPropertyValue('--text-primary').trim() || '#333333' - const line = styles.getPropertyValue('--text-tertiary').trim() || '#999999' - return { text, line } + const cssVar = (name: string, fallback: string) => styles.getPropertyValue(name).trim() || fallback + return { + text: cssVar('--text-primary', '#333333'), + secondaryText: cssVar('--text-secondary', '#666666'), + mutedText: cssVar('--text-tertiary', '#999999'), + line: cssVar('--border-color', '#e5e5e5'), + surface: cssVar('--card-inner-bg', '#ffffff'), + border: cssVar('--border-color', '#e5e5e5'), + primary: cssVar('--primary', '#10a37f'), + primaryLight: cssVar('--primary-light', 'rgba(16, 163, 127, 0.1)'), + danger: cssVar('--danger', '#ef4444'), + warning: cssVar('--warning', '#f59e0b'), + success: cssVar('--primary', '#10a37f'), + info: '#3b82f6' + } } - const chartLabelColors = getChartLabelColors() + const chartTheme = getChartTheme() const getTypeChartOption = () => { if (!statistics) return {} @@ -309,7 +369,7 @@ function AnalyticsPage() { show: true, formatter: '{b}\n{d}%', textStyle: { - color: chartLabelColors.text, + color: chartTheme.text, textShadowBlur: 0, textShadowColor: 'transparent', textShadowOffsetX: 0, @@ -320,7 +380,7 @@ function AnalyticsPage() { }, labelLine: { lineStyle: { - color: chartLabelColors.line, + color: chartTheme.mutedText, shadowBlur: 0, shadowColor: 'transparent', }, @@ -332,7 +392,7 @@ function AnalyticsPage() { shadowOffsetY: 0, }, label: { - color: chartLabelColors.text, + color: chartTheme.text, textShadowBlur: 0, textShadowColor: 'transparent', textBorderWidth: 0, @@ -340,7 +400,7 @@ function AnalyticsPage() { }, labelLine: { lineStyle: { - color: chartLabelColors.line, + color: chartTheme.mutedText, shadowBlur: 0, shadowColor: 'transparent', }, @@ -364,7 +424,7 @@ function AnalyticsPage() { show: true, formatter: '{b}: {c}', textStyle: { - color: chartLabelColors.text, + color: chartTheme.text, textShadowBlur: 0, textShadowColor: 'transparent', textShadowOffsetX: 0, @@ -375,7 +435,7 @@ function AnalyticsPage() { }, labelLine: { lineStyle: { - color: chartLabelColors.line, + color: chartTheme.mutedText, shadowBlur: 0, shadowColor: 'transparent', }, @@ -387,7 +447,7 @@ function AnalyticsPage() { shadowOffsetY: 0, }, label: { - color: chartLabelColors.text, + color: chartTheme.text, textShadowBlur: 0, textShadowColor: 'transparent', textBorderWidth: 0, @@ -395,7 +455,7 @@ function AnalyticsPage() { }, labelLine: { lineStyle: { - color: chartLabelColors.line, + color: chartTheme.mutedText, shadowBlur: 0, shadowColor: 'transparent', }, @@ -417,6 +477,160 @@ function AnalyticsPage() { } } + const getSelfSentDailyRatioData = () => { + const entries = Object.entries(selfSentDailyDistribution?.dailyDistribution || {}) + .sort(([a], [b]) => a.localeCompare(b)) + const days = entries.map(([day]) => day) + const counts = entries.map(([, count]) => count) + const totalDays = Math.max(days.length, 1) + const total = counts.reduce((sum, count) => sum + count, 0) + const baseline = total > 0 ? total / totalDays : 0 + const ratios = counts.map((count) => baseline > 0 ? Number((count / baseline * 100).toFixed(1)) : 0) + const movingAverage = ratios.map((_, index) => { + const start = Math.max(0, index - 6) + const windowValues = ratios.slice(start, index + 1) + const sum = windowValues.reduce((total, value) => total + value, 0) + return Number((sum / windowValues.length).toFixed(1)) + }) + return { days, counts, ratios, movingAverage, baseline, total } + } + + const getSelfSentDailyRatioOption = () => { + if (!selfSentDailyDistribution) return {} + const { days, counts, ratios, movingAverage, baseline } = getSelfSentDailyRatioData() + const showZoom = days.length > 31 + + const zoomStart = showZoom ? Math.max(0, 100 - Math.min(100, 31 / days.length * 100)) : 0 + const ratioBarColors = { + normal: chartTheme.primary, + high: chartTheme.warning, + spike: chartTheme.danger, + trend: chartTheme.secondaryText, + baseline: chartTheme.mutedText + } + + return { + tooltip: { + trigger: 'axis', + backgroundColor: chartTheme.surface, + borderColor: chartTheme.border, + textStyle: { color: chartTheme.text }, + extraCssText: 'box-shadow: var(--shadow-md); border-radius: 8px;', + axisPointer: { + type: 'shadow', + shadowStyle: { color: chartTheme.primaryLight } + }, + formatter: (params: any) => { + const items = Array.isArray(params) ? params : [params] + const first = items[0] + const index = Number(first?.dataIndex || 0) + const lines = [ + `${first?.axisValue || ''}`, + `当日发送:${formatNumber(counts[index] || 0)} 条`, + `相对日均:${formatNumber(ratios[index] || 0)}%`, + `7日均线:${formatNumber(movingAverage[index] || 0)}%`, + `全期日均:${baseline.toFixed(1)} 条/天` + ] + return lines.join('
') + } + }, + legend: { + data: ['单日比例', '7日均线'], + top: 0, + textStyle: { color: chartTheme.secondaryText } + }, + grid: { left: 56, right: 40, top: 42, bottom: showZoom ? 58 : 32 }, + xAxis: { + type: 'category', + data: days, + axisLine: { lineStyle: { color: chartTheme.line } }, + axisTick: { lineStyle: { color: chartTheme.line } }, + axisLabel: { + color: chartTheme.mutedText, + hideOverlap: true, + formatter: (value: string) => value.slice(5) + } + }, + yAxis: { + type: 'value', + name: '相对日均', + nameTextStyle: { color: chartTheme.mutedText }, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { + color: chartTheme.mutedText, + formatter: '{value}%' + }, + splitLine: { lineStyle: { color: chartTheme.line, type: 'dashed' } } + }, + dataZoom: showZoom ? [ + { type: 'inside', start: zoomStart, end: 100 }, + { + type: 'slider', + height: 18, + bottom: 16, + start: zoomStart, + end: 100, + borderColor: chartTheme.border, + fillerColor: chartTheme.primaryLight, + handleStyle: { color: chartTheme.primary, borderColor: chartTheme.primary }, + moveHandleStyle: { color: chartTheme.primaryLight }, + dataBackground: { + lineStyle: { color: chartTheme.mutedText }, + areaStyle: { color: chartTheme.primaryLight } + }, + selectedDataBackground: { + lineStyle: { color: chartTheme.primary }, + areaStyle: { color: chartTheme.primaryLight } + }, + textStyle: { color: chartTheme.mutedText } + } + ] : undefined, + series: [ + { + name: '单日比例', + type: 'bar', + data: ratios, + itemStyle: { + color: (params: any) => { + const value = Number(params?.value || 0) + if (value >= 200) return ratioBarColors.spike + if (value >= 150) return ratioBarColors.high + return ratioBarColors.normal + }, + borderRadius: [4, 4, 0, 0] + }, + markLine: { + symbol: 'none', + data: [{ yAxis: 100, name: '日均基线' }], + label: { + position: 'middle', + formatter: '日均基线', + color: chartTheme.secondaryText, + backgroundColor: chartTheme.surface, + borderColor: chartTheme.border, + borderWidth: 1, + borderRadius: 4, + padding: [2, 6] + }, + lineStyle: { type: 'dashed', color: ratioBarColors.baseline } + } + }, + { + name: '7日均线', + type: 'line', + data: movingAverage, + smooth: true, + showSymbol: false, + lineStyle: { width: 2, color: ratioBarColors.trend }, + itemStyle: { color: ratioBarColors.trend } + } + ] + } + } + + const selfSentDailyRatioData = getSelfSentDailyRatioData() + const renderPageShell = (content: ReactNode) => (
@@ -521,6 +735,16 @@ function AnalyticsPage() {

消息类型分布

发送/接收比例

每小时消息分布

+
+
+

每日自身发送强度比例

+ 范围:全部 · 基线:{selfSentDailyRatioData.baseline.toFixed(1)} 条/天 · 共 {formatNumber(selfSentDailyDistribution?.totalMessages || 0)} 条 +
+
+ 比例 = 当日自身发送量 ÷ 全期每日平均自身发送量。超过 100% 表示高于本人基线 +
+ +
diff --git a/src/stores/analyticsStore.ts b/src/stores/analyticsStore.ts index 7cc9559..c696036 100644 --- a/src/stores/analyticsStore.ts +++ b/src/stores/analyticsStore.ts @@ -32,11 +32,22 @@ interface TimeDistribution { monthlyDistribution: Record } +interface SelfSentDailyDistribution { + unit: 'day' + dailyDistribution: Record + totalMessages: number + firstMessageTime: number | null + lastMessageTime: number | null + beginTimestamp: number + endTimestamp: number +} + interface AnalyticsState { // 数据 statistics: ChatStatistics | null rankings: ContactRanking[] timeDistribution: TimeDistribution | null + selfSentDailyDistribution: SelfSentDailyDistribution | null // 状态 isLoaded: boolean @@ -46,6 +57,7 @@ interface AnalyticsState { setStatistics: (data: ChatStatistics) => void setRankings: (data: ContactRanking[]) => void setTimeDistribution: (data: TimeDistribution) => void + setSelfSentDailyDistribution: (data: SelfSentDailyDistribution) => void markLoaded: () => void clearCache: () => void } @@ -56,17 +68,20 @@ export const useAnalyticsStore = create()( statistics: null, rankings: [], timeDistribution: null, + selfSentDailyDistribution: null, isLoaded: false, lastLoadTime: null, setStatistics: (data) => set({ statistics: data }), setRankings: (data) => set({ rankings: data }), setTimeDistribution: (data) => set({ timeDistribution: data }), + setSelfSentDailyDistribution: (data) => set({ selfSentDailyDistribution: data }), markLoaded: () => set({ isLoaded: true, lastLoadTime: Date.now() }), clearCache: () => set({ statistics: null, rankings: [], timeDistribution: null, + selfSentDailyDistribution: null, isLoaded: false, lastLoadTime: null }), diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 06c55d0..b927e4f 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -929,6 +929,19 @@ export interface ElectronAPI { } error?: string }> + getSelfSentDailyDistribution: (beginTimestamp?: number, endTimestamp?: number, force?: boolean) => Promise<{ + success: boolean + data?: { + unit: 'day' + dailyDistribution: Record + totalMessages: number + firstMessageTime: number | null + lastMessageTime: number | null + beginTimestamp: number + endTimestamp: number + } + error?: string + }> getExcludedUsernames: () => Promise<{ success: boolean data?: string[]