Merge pull request #1030 from clearyss/main

feat: 私聊分析新增自身强度统计,针对mimoAPI调用增强
This commit is contained in:
cc
2026-05-29 19:47:50 +08:00
committed by GitHub
8 changed files with 650 additions and 64 deletions

View File

@@ -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()
})

View File

@@ -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'),

View File

@@ -27,6 +27,16 @@ export interface TimeDistribution {
monthlyDistribution: Record<string, number>
}
export interface SelfSentDailyDistribution {
unit: 'day'
dailyDistribution: Record<string, number>
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<string, any>) => void,
beginTimestamp = 0,
endTimestamp = 0
endTimestamp = 0,
lite = false
): Promise<void> {
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<string, any>): 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<string, any>, 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<string, number>): Record<string, number> {
const sorted: Record<string, number> = {}
for (const key of Object.keys(daily).sort()) {
sorted[key] = daily[key]
}
return sorted
}
private completeDailyDistribution(
daily: Record<string, number>,
firstTimestamp: number,
lastTimestamp: number
): Record<string, number> {
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<string, number> = {}
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<number, number>,
weekday: {} as Record<number, number>,
daily: {} as Record<string, number>,
sentDaily: {} as Record<string, number>,
monthly: {} as Record<string, number>,
sessions: {} as Record<string, { total: number; sent: number; received: number; lastTime: number }>,
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<SelfSentDailyDistribution> {
const dailyDistribution: Record<string, number> = {}
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 })

View File

@@ -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<string> {
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<string, unknown> = {
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()

View File

@@ -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

View File

@@ -36,8 +36,20 @@ function AnalyticsPage() {
const [excludedUsernames, setExcludedUsernames] = useState<Set<string>>(new Set())
const [draftExcluded, setDraftExcluded] = useState<Set<string>>(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('<br/>')
}
},
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) => (
<div className="analytics-page-shell">
<ChatAnalysisHeader currentMode="private" />
@@ -521,6 +735,16 @@ function AnalyticsPage() {
<div className="chart-card"><h3></h3><ReactECharts option={getTypeChartOption()} style={{ height: 300 }} /></div>
<div className="chart-card"><h3>/</h3><ReactECharts option={getSendReceiveOption()} style={{ height: 300 }} /></div>
<div className="chart-card wide"><h3></h3><ReactECharts option={getHourlyOption()} style={{ height: 250 }} /></div>
<div className="chart-card wide self-sent-ratio-card">
<div className="chart-title-row">
<h3></h3>
<span> · 线{selfSentDailyRatioData.baseline.toFixed(1)} / · {formatNumber(selfSentDailyDistribution?.totalMessages || 0)} </span>
</div>
<div className="chart-note">
= ÷ 100% 线
</div>
<ReactECharts key={chartThemeSignature} option={getSelfSentDailyRatioOption()} style={{ height: 320 }} />
</div>
</div>
</section>
<section className="page-section">

View File

@@ -32,11 +32,22 @@ interface TimeDistribution {
monthlyDistribution: Record<string, number>
}
interface SelfSentDailyDistribution {
unit: 'day'
dailyDistribution: Record<string, number>
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<AnalyticsState>()(
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
}),

View File

@@ -929,6 +929,19 @@ export interface ElectronAPI {
}
error?: string
}>
getSelfSentDailyDistribution: (beginTimestamp?: number, endTimestamp?: number, force?: boolean) => Promise<{
success: boolean
data?: {
unit: 'day'
dailyDistribution: Record<string, number>
totalMessages: number
firstMessageTime: number | null
lastMessageTime: number | null
beginTimestamp: number
endTimestamp: number
}
error?: string
}>
getExcludedUsernames: () => Promise<{
success: boolean
data?: string[]