From 4a170cb33c5011fda254a0e9ac6e2518a6bdf727 Mon Sep 17 00:00:00 2001 From: Symb0x76 <9667434@qq.com> Date: Sun, 5 Oct 2025 20:58:11 +0800 Subject: [PATCH] feat(frontend): add calendar for diary & chatlog date selection --- internal/chatlog/http/route.go | 908 +++++++------------------ internal/chatlog/http/static/index.htm | 129 +++- 2 files changed, 363 insertions(+), 674 deletions(-) diff --git a/internal/chatlog/http/route.go b/internal/chatlog/http/route.go index a01a854..3e383f4 100644 --- a/internal/chatlog/http/route.go +++ b/internal/chatlog/http/route.go @@ -75,7 +75,6 @@ func (s *Service) initAPIRouter() { api.GET("/chatroom", s.handleChatRooms) api.GET("/session", s.handleSessions) api.GET("/diary", s.handleDiary) - api.GET("/summary", s.handleSummary) api.GET("/dashboard", s.handleDashboard) } } @@ -86,205 +85,6 @@ func (s *Service) initMCPRouter() { s.router.Any("/message", func(c *gin.Context) { s.mcpSSEServer.ServeHTTP(c.Writer, c.Request) }) } -// handleSummary outputs a dashboard summary JSON. For now it serves a template JSON -// compatible with tools/json/index.json. In future we can compute real data. -// GET /api/v1/summary -func (s *Service) handleSummary(c *gin.Context) { - // dynamic=1 triggers dynamic summary generation; otherwise fall back to template JSON - dynamic := c.Query("dynamic") == "1" - - // Try to load a template JSON from tools/json/index.json if present - // Otherwise, return an empty structure with HTTP 200. - workdir := s.conf.GetDataDir() - candidates := []string{ - filepath.Join("tools", "json", "index.json"), - filepath.Join(workdir, "tools", "json", "index.json"), - } - var raw []byte - for _, p := range candidates { - if b, err := os.ReadFile(p); err == nil && len(b) > 0 { - raw = b - break - } - } - - var v any - if dynamic { - // compute dynamic summary - sum := s.computeDynamicSummary() - v = sum - } else { - if len(raw) > 0 { - if err := json.Unmarshal(raw, &v); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid summary template", "detail": err.Error()}) - return - } - } else { - v = gin.H{"dashboard_report": gin.H{}} - } - } - - // Optional: save to file in workdir when save=1 - if c.Query("save") == "1" { - // default filename - filename := c.DefaultQuery("filename", "summary.json") - if filename == "" { - filename = "summary.json" - } - outPath := filepath.Join(s.conf.GetDataDir(), filename) - if dir := filepath.Dir(outPath); dir != "." { - _ = os.MkdirAll(dir, 0o755) - } - var out []byte - var err error - if !dynamic && len(raw) > 0 { - out = raw - } else { - out, err = json.MarshalIndent(v, "", " ") - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to marshal summary", "detail": err.Error()}) - return - } - } - if err := os.WriteFile(outPath, out, 0o644); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save summary", "detail": err.Error()}) - return - } - // If download=1, stream the file as attachment - if c.Query("download") == "1" { - c.Header("Content-Type", "application/json") - c.Header("Content-Disposition", "attachment; filename="+filepath.Base(outPath)) - c.Data(http.StatusOK, "application/json", out) - return - } - c.JSON(http.StatusOK, gin.H{"saved": true, "path": outPath}) - return - } - - // Optional: direct download when download=1 - if c.Query("download") == "1" { - b := raw - if dynamic || len(b) == 0 { - var err error - b, err = json.MarshalIndent(v, "", " ") - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to marshal summary", "detail": err.Error()}) - return - } - } - c.Header("Content-Type", "application/json") - c.Header("Content-Disposition", "attachment; filename=summary.json") - c.Data(http.StatusOK, "application/json", b) - return - } - - c.JSON(http.StatusOK, v) -} - -// computeDynamicSummary builds a lightweight dynamic dashboard JSON with basic metrics. -// It avoids heavy full DB scans and uses existing repository APIs for acceptable performance. -func (s *Service) computeDynamicSummary() any { - // Sizes - dataDir := s.conf.GetDataDir() - workDir := dataDir // prefer dataDir for media; if database layer exposes workDir, use it - if s.db != nil { - if wd := s.db.GetWorkDir(); wd != "" { - workDir = wd - } - } - dirSize := safeDirSize(dataDir) - dbSize := estimateDBSize(workDir) - - // Sessions timeline (approximate earliest/latest by NTime) - minTime := time.Time{} - maxTime := time.Time{} - if sessions, err := s.db.GetSessions("", 0, 0); err == nil { - for _, it := range sessions.Items { - t := it.NTime - if t.IsZero() { - continue - } - if minTime.IsZero() || t.Before(minTime) { - minTime = t - } - if maxTime.IsZero() || t.After(maxTime) { - maxTime = t - } - } - } - - // Contacts basic stats - totalContacts := 0 - friends := 0 - nonFriends := 0 - if contacts, err := s.db.GetContacts("", 0, 0); err == nil { - totalContacts = len(contacts.Items) - for _, c := range contacts.Items { - if c.IsFriend { - friends++ - } else { - nonFriends++ - } - } - } - - // Chatrooms top by member count - roomsList := make([]map[string]any, 0) - if rooms, err := s.db.GetChatRooms("", 0, 0); err == nil { - for _, r := range rooms.Items { - roomsList = append(roomsList, map[string]any{ - "name": r.Name, - "nick": r.NickName, - "owner": r.Owner, - "members": len(r.Users), - }) - } - // simple sort: descending by members - // do inline selection sort to avoid importing sort for tiny lists - for i := 0; i < len(roomsList); i++ { - maxIdx := i - for j := i + 1; j < len(roomsList); j++ { - if roomsList[j]["members"].(int) > roomsList[maxIdx]["members"].(int) { - maxIdx = j - } - } - if maxIdx != i { - roomsList[i], roomsList[maxIdx] = roomsList[maxIdx], roomsList[i] - } - } - if len(roomsList) > 20 { - roomsList = roomsList[:20] - } - } - - // Build JSON structure - dash := map[string]any{ - "db_stats": map[string]any{ - "db_size_mb": roundMB(dbSize), - "dir_size_mb": roundMB(dirSize), - }, - "timeline": map[string]any{ - "start": formatTime(minTime), - "end": formatTime(maxTime), - "days": diffDays(minTime, maxTime), - }, - "contact_stats": map[string]any{ - "total": totalContacts, - "friends": friends, - "non_friends": nonFriends, - }, - "group_stats": map[string]any{ - "top_member_groups": roomsList, - }, - // Placeholders for future dynamic enrichment - "message_stats": map[string]any{ - "total": 0, - "by_type": map[string]int{}, - }, - } - return map[string]any{"dashboard_report": dash} -} - // handleDashboard 返回真实统计数据的仪表盘 JSON // GET /api/v1/dashboard func (s *Service) handleDashboard(c *gin.Context) { @@ -295,15 +95,6 @@ func (s *Service) handleDashboard(c *gin.Context) { return } groupCounts, _ := s.db.GetDB().GroupMessageCounts() - groupHourlyRaw, _ := s.db.GetDB().GroupTodayHourly() - - type HourlySeries struct { - Hour string `json:"hour"` - Messages int64 `json:"messages"` - } - - trends, _ := s.db.GetDB().MonthlyTrend(0) - heat, _ := s.db.GetDB().Heatmap() // 文件与目录大小 dataDir := s.conf.GetDataDir() @@ -375,41 +166,175 @@ func (s *Service) handleDashboard(c *gin.Context) { } } - // 联系人统计 + // 使用结构体固定 JSON 输出顺序 + type DBStats struct { + DbSizeMB float64 `json:"db_size_mb"` + DirSizeMB float64 `json:"dir_size_mb"` + } + type MsgStats struct { + TotalMsgs int64 `json:"total_msgs"` + SentMsgs int64 `json:"sent_msgs"` + ReceivedMsgs int64 `json:"received_msgs"` + UniqueMsgTypes int `json:"unique_msg_types"` + } + type OverviewGroup struct { + ChatRoomName string `json:"ChatRoomName"` + NickName string `json:"NickName"` + MemberCount int `json:"member_count"` + MessageCount int64 `json:"message_count"` + } + type Timeline struct { + Earliest int64 `json:"earliest_msg_time"` + Latest int64 `json:"latest_msg_time"` + Duration int `json:"duration_days"` + } + type Migration struct { + ID int `json:"id"` + File string `json:"file"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + } + type Overview struct { + User string `json:"user"` + DBStats DBStats `json:"dbStats"` + MsgStats MsgStats `json:"msgStats"` + MsgTypes map[string]int64 `json:"msgTypes"` + Groups []OverviewGroup `json:"groups"` + Timeline Timeline `json:"timeline"` + Migrations []Migration `json:"migrations"` + } + + type GroupOverview struct { + TotalGroups int `json:"total_groups"` + ActiveGroups int `json:"active_groups"` + TodayMessages int `json:"today_messages"` + WeeklyAvg int `json:"weekly_avg"` + MostActiveHour string `json:"most_active_hour"` + } + type ContentAnalysis struct { + Text int64 `json:"text_messages"` + Images int64 `json:"images"` + Voice int64 `json:"voice_messages"` + Files int64 `json:"files"` + Links int64 `json:"links"` + Others int64 `json:"others"` + } + type GroupListItem struct { + Name string `json:"name"` + Members int `json:"members"` + Messages int64 `json:"messages"` + Active bool `json:"active"` + } + type GroupAnalysis struct { + Title string `json:"title"` + Overview GroupOverview `json:"overview"` + ContentAnalysis ContentAnalysis `json:"content_analysis"` + GroupList []GroupListItem `json:"group_list"` + } + type ContentTypeStats struct { + Count int64 `json:"count"` + Percentage float64 `json:"percentage"` + SizeMB *float64 `json:"size_mb,omitempty"` + Trend *string `json:"trend,omitempty"` + } + type SourceChannel struct { + Count int64 `json:"count"` + Percentage float64 `json:"percentage"` + } + type ProcessingStatus struct { + Processed int `json:"processed"` + Processing int `json:"processing"` + Pending int `json:"pending"` + } + type QualityMetrics struct { + DataIntegrity float64 `json:"data_integrity"` + ClassificationAccuracy float64 `json:"classification_accuracy"` + DuplicateRate float64 `json:"duplicate_rate"` + ErrorRate float64 `json:"error_rate"` + } + type DataTypeAnalysis struct { + Title string `json:"title"` + ContentTypes map[string]ContentTypeStats `json:"content_types"` + SourceChannels map[string]SourceChannel `json:"source_channels"` + ProcessingStatus ProcessingStatus `json:"processing_status"` + QualityMetrics QualityMetrics `json:"quality_metrics"` + PieGradient string `json:"pieGradient,omitempty"` + } + type VisualizationDefaults struct { + SelectedGroupIndex int `json:"selectedGroupIndex"` + } + type RelationshipNode struct { + Name string `json:"name"` + Type string `json:"type"` + Messages int64 `json:"messages"` + Avatar string `json:"avatar,omitempty"` + } + type RelationshipNetwork struct { + Nodes []RelationshipNode `json:"nodes"` + } + type Visualization struct { + Defaults VisualizationDefaults `json:"defaults"` + GroupAnalysis GroupAnalysis `json:"groupAnalysis"` + DataTypeAnalysis DataTypeAnalysis `json:"dataTypeAnalysis"` + RelationshipNetwork RelationshipNetwork `json:"relationshipNetwork"` + } + type Dashboard struct { + Overview Overview `json:"overview"` + Visualization Visualization `json:"visualization"` + } // 群信息(合并消息计数) - overviewGroups := make([]map[string]any, 0) - groupNameMap := make(map[string]string) + type groupAggregate struct { + id string + nickName string + members int + messages int64 + active bool + } + groupAggs := make([]groupAggregate, 0) activeGroups := 0 if rooms, err := s.db.GetChatRooms("", 0, 0); err == nil { for _, r := range rooms.Items { - // 跳过没有 NickName 的群 if strings.TrimSpace(r.NickName) == "" { continue } mc := groupCounts[r.Name] - if mc > 0 { + active := mc > 0 + if active { activeGroups++ } - groupNameMap[r.Name] = r.NickName - arr := groupHourlyRaw[r.Name] - series := make([]HourlySeries, 0, 24) - for h := 0; h < 24; h++ { - if arr[h] == 0 { - continue - } - series = append(series, HourlySeries{Hour: fmt.Sprintf("%02d:00", h), Messages: arr[h]}) - } - overviewGroups = append(overviewGroups, map[string]any{ - "ChatRoomName": r.Name, - "NickName": r.NickName, - "member_count": len(r.Users), - "message_count": mc, - "active": mc > 0, - "hourly_activity": series, + groupAggs = append(groupAggs, groupAggregate{ + id: r.Name, + nickName: r.NickName, + members: len(r.Users), + messages: mc, + active: active, }) } } + sort.Slice(groupAggs, func(i, j int) bool { + if groupAggs[i].messages == groupAggs[j].messages { + return groupAggs[i].nickName < groupAggs[j].nickName + } + return groupAggs[i].messages > groupAggs[j].messages + }) + overviewGroups := make([]OverviewGroup, 0, len(groupAggs)) + groupList := make([]GroupListItem, 0, len(groupAggs)) + for _, g := range groupAggs { + overviewGroups = append(overviewGroups, OverviewGroup{ + ChatRoomName: g.id, + NickName: g.nickName, + MemberCount: g.members, + MessageCount: g.messages, + }) + groupList = append(groupList, GroupListItem{ + Name: g.nickName, + Members: g.members, + Messages: g.messages, + Active: g.active, + }) + } + totalGroups := len(groupAggs) // msgTypes 依据最新文档 + 衍生细分(文件消息 / 链接消息)补齐 msgTypes := map[string]int64{ @@ -435,17 +360,20 @@ func (s *Service) handleDashboard(c *gin.Context) { } // 时间轴 - durationDays := 0.0 + durationDays := 0 if gstats.EarliestUnix > 0 && gstats.LatestUnix >= gstats.EarliestUnix { - durationDays = float64(gstats.LatestUnix-gstats.EarliestUnix) / 86400.0 - durationDays = math.Round(durationDays*100) / 100.0 + span := gstats.LatestUnix - gstats.EarliestUnix + if span < 0 { + span = 0 + } + durationDays = int(math.Round(float64(span) / 86400.0)) } - // trends 排序 - sort.Slice(trends, func(i, j int) bool { return trends[i].Date < trends[j].Date }) - trendData := make([]map[string]any, 0, len(trends)) - for _, t := range trends { - trendData = append(trendData, map[string]any{"date": t.Date, "sent": t.Sent, "received": t.Received}) + uniqueTypes := 0 + for _, v := range msgTypes { + if v > 0 { + uniqueTypes++ + } } // 今日每小时统计用于 most_active_hour @@ -480,156 +408,6 @@ func (s *Service) handleDashboard(c *gin.Context) { } privateTotal := totalMsgs - groupTotal - // 使用结构体固定 JSON 输出顺序 - type DBStats struct { - DbSizeMB float64 `json:"db_size_mb"` - DirSizeMB float64 `json:"dir_size_mb"` - } - type MsgStats struct { - TotalMsgs int64 `json:"total_msgs"` - SentMsgs int64 `json:"sent_msgs"` - ReceivedMsgs int64 `json:"received_msgs"` - UniqueMsgTypes int `json:"unique_msg_types"` - } - type OverviewGroup struct { - ChatRoomName string `json:"ChatRoomName"` - NickName string `json:"NickName"` - MemberCount int `json:"member_count"` - MessageCount int64 `json:"message_count"` - Active bool `json:"active"` - HourlyActivity []HourlySeries `json:"hourly_activity"` - } - type Timeline struct { - Earliest int64 `json:"earliest_msg_time"` - Latest int64 `json:"latest_msg_time"` - Duration float64 `json:"duration_days"` - } - type Migration struct { - ID int `json:"id"` - File string `json:"file"` - Status string `json:"status"` - CreatedAt string `json:"created_at"` - } - type Overview struct { - User string `json:"user"` - DBStats DBStats `json:"dbStats"` - MsgStats MsgStats `json:"msgStats"` - MsgTypes map[string]int64 `json:"msgTypes"` - Groups []OverviewGroup `json:"groups"` - Timeline Timeline `json:"timeline"` - Migrations []Migration `json:"migrations"` - } - - type TrendPoint struct { - Date string `json:"date"` - Sent int64 `json:"sent"` - Received int64 `json:"received"` - } - // Trend 增强结构在 TrendPoint 之后定义 - type TrendPct struct { - Date string `json:"date"` - SentPct float64 `json:"sent_pct"` - ReceivedPct float64 `json:"received_pct"` - } - type TrendEnhanced struct { - YAxisTicks []int `json:"yAxisTicks"` - TrendData []TrendPoint `json:"trendData"` - TrendDataPercent []TrendPct `json:"trendDataPercent"` - } - type HeatmapRow struct { - Hour int `json:"hour"` - Monday int64 `json:"monday"` - Tuesday int64 `json:"tuesday"` - Wednesday int64 `json:"wednesday"` - Thursday int64 `json:"thursday"` - Friday int64 `json:"friday"` - Saturday int64 `json:"saturday"` - Sunday int64 `json:"sunday"` - } - - type GroupOverview struct { - TotalGroups int `json:"total_groups"` - ActiveGroups int `json:"active_groups"` - TodayMessages int `json:"today_messages"` - WeeklyAvg int `json:"weekly_avg"` - MostActiveHour string `json:"most_active_hour"` - } - type ContentAnalysis struct { - Text int64 `json:"text_messages"` - Images int64 `json:"images"` - Voice int64 `json:"voice_messages"` - Files int64 `json:"files"` - Links int64 `json:"links"` - Others int64 `json:"others"` - } - type GroupAnalysis struct { - Title string `json:"title"` - Overview GroupOverview `json:"overview"` - ContentAnalysis ContentAnalysis `json:"content_analysis"` - } - type ContentTypeStats struct { - Count int64 `json:"count"` - Percentage float64 `json:"percentage"` - } - type SourceChannel struct { - Count int64 `json:"count"` - Percentage float64 `json:"percentage"` - } - type DataTypeAnalysis struct { - Title string `json:"title"` - ContentTypes map[string]ContentTypeStats `json:"content_types"` - SourceChannels map[string]SourceChannel `json:"source_channels"` - } - type Visualization struct { - TrendData []TrendPoint `json:"trendData"` - HeatmapData []HeatmapRow `json:"heatmapData"` - GroupAnalysis GroupAnalysis `json:"groupAnalysis"` - DataTypeAnalysis DataTypeAnalysis `json:"dataTypeAnalysis"` - Trend TrendEnhanced `json:"trend"` - } - - type Network struct { - Nodes []any `json:"nodes"` - Links []any `json:"links"` - } - type Dashboard struct { - Overview Overview `json:"overview"` - Visualization Visualization `json:"visualization"` - Network Network `json:"network"` - } - - ogroups := make([]OverviewGroup, 0, len(overviewGroups)) - for _, g := range overviewGroups { - series := []HourlySeries{} - if raw, ok := g["hourly_activity"].([]HourlySeries); ok { - series = append(series, raw...) - } - ogroups = append(ogroups, OverviewGroup{ - ChatRoomName: g["ChatRoomName"].(string), - NickName: g["NickName"].(string), - MemberCount: g["member_count"].(int), - MessageCount: g["message_count"].(int64), - Active: g["active"].(bool), - HourlyActivity: series, - }) - } - tpoints := make([]TrendPoint, 0, len(trendData)) - for _, t := range trendData { - tpoints = append(tpoints, TrendPoint{Date: t["date"].(string), Sent: t["sent"].(int64), Received: t["received"].(int64)}) - } - hrows := make([]HeatmapRow, 0, 24) - for h := 0; h < 24; h++ { - hrows = append(hrows, HeatmapRow{ - Hour: h, - Monday: heat[h][1], - Tuesday: heat[h][2], - Wednesday: heat[h][3], - Thursday: heat[h][4], - Friday: heat[h][5], - Saturday: heat[h][6], - Sunday: heat[h][0], - }) - } // ====== 今日群聊消息数统计 ====== todayMessages := int64(0) if s.db != nil && s.db.GetDB() != nil { @@ -697,184 +475,16 @@ func (s *Service) handleDashboard(c *gin.Context) { ctPerc[maxKey] = round2(ctPerc[maxKey] + diff) } - // 读取历史快照生成增强趋势 - buildTrend := func() TrendEnhanced { - baseDir := "" - if s.db != nil { - if wd := s.db.GetWorkDir(); wd != "" { - baseDir = wd - } - } - if baseDir == "" { - baseDir = s.conf.GetDataDir() - } - trend := TrendEnhanced{YAxisTicks: []int{}, TrendData: tpoints, TrendDataPercent: []TrendPct{}} - if baseDir == "" { - return trend - } - dir := filepath.Join(baseDir, "dashboards") - entries, err := os.ReadDir(dir) - if err != nil { - return trend - } - // 聚合:按月份汇总 sent / received - type agg struct{ sent, recv int64 } - monthly := make(map[string]*agg) - // 加入当前内存趋势数据作为基线 - for _, p := range tpoints { - if monthly[p.Date] == nil { - monthly[p.Date] = &agg{} - } - monthly[p.Date].sent += p.Sent - monthly[p.Date].recv += p.Received - } - // 解析历史文件(限制最多 500 个,最新优先无需排序严格,简单遍历即可) - processed := 0 - for _, e := range entries { - if e.IsDir() || !strings.HasPrefix(e.Name(), "dashboard_") || !strings.HasSuffix(e.Name(), ".json") { - continue - } - path := filepath.Join(dir, e.Name()) - b, err := os.ReadFile(path) - if err != nil { - continue - } - var snap struct { - Overview struct { - MsgStats struct { - SentMsgs int64 `json:"sent_msgs"` - ReceivedMsgs int64 `json:"received_msgs"` - } - Timeline struct { - Latest int64 `json:"latest_msg_time"` - } `json:"timeline"` - } `json:"overview"` - Visualization struct { - TrendData []TrendPoint `json:"trendData"` - } `json:"visualization"` - } - if json.Unmarshal(b, &snap) != nil { - continue - } - for _, tp := range snap.Visualization.TrendData { - if monthly[tp.Date] == nil { - monthly[tp.Date] = &agg{} - } - monthly[tp.Date].sent += tp.Sent - monthly[tp.Date].recv += tp.Received - } - processed++ - if processed > 500 { - break - } - } - // 生成有序日期列表(按 YYYY-MM 排序) - dates := make([]string, 0, len(monthly)) - for d := range monthly { - dates = append(dates, d) - } - sort.Strings(dates) - series := make([]TrendPoint, 0, len(dates)) - var maxVal int64 - for _, d := range dates { - a := monthly[d] - series = append(series, TrendPoint{Date: d, Sent: a.sent, Received: a.recv}) - if a.sent > maxVal { - maxVal = a.sent - } - if a.recv > maxVal { - maxVal = a.recv - } - } - // Y 轴刻度(最大值向上取整到 500/1000 级别) - if maxVal <= 0 { - trend.YAxisTicks = []int{0} - } else { - step := int64(500) - if maxVal > 5000 { - step = 1000 - } - ceil := ((maxVal + step - 1) / step) * step - for v := ceil; v >= 0; v -= step { - trend.YAxisTicks = append(trend.YAxisTicks, int(v)) - } - } - // 百分比序列 - pcts := make([]TrendPct, 0, len(series)) - for _, pt := range series { - tot := pt.Sent + pt.Received - sp, rp := 0.0, 0.0 - if tot > 0 { - sp = math.Round(float64(pt.Sent)*10000/float64(tot)) / 100 - rp = math.Round(float64(pt.Received)*10000/float64(tot)) / 100 - // 差值调和(避免 99.99/100.01) - diff := math.Round((100-(sp+rp))*100) / 100 - if diff != 0 { - if rp >= sp { - rp += diff - } else { - sp += diff - } - } - } - pcts = append(pcts, TrendPct{Date: pt.Date, SentPct: sp, ReceivedPct: rp}) - } - trend.TrendData = series - trend.TrendDataPercent = pcts - return trend - } - trendEnhanced := buildTrend() - - vis := Visualization{ - TrendData: tpoints, - HeatmapData: hrows, - GroupAnalysis: GroupAnalysis{ - Title: "群聊分析", - Overview: GroupOverview{TotalGroups: len(overviewGroups), ActiveGroups: activeGroups, TodayMessages: int(todayMessages), WeeklyAvg: weeklyAvg, MostActiveHour: mostActiveHour}, - // 扩展:增加 links 字段(结构体需更新) - ContentAnalysis: ContentAnalysis{Text: msgTypes["文本消息"], Images: msgTypes["图片消息"], Voice: msgTypes["语音消息"], Files: msgTypes["文件消息"], Links: msgTypes["链接消息"], Others: totalMsgs - (msgTypes["文本消息"] + msgTypes["图片消息"] + msgTypes["语音消息"] + msgTypes["文件消息"] + msgTypes["链接消息"])}, - }, - DataTypeAnalysis: DataTypeAnalysis{ - Title: "数据类型统计", - ContentTypes: map[string]ContentTypeStats{ - "文本消息": {Count: msgTypes["文本消息"], Percentage: ctPerc["文本消息"]}, - "图片消息": {Count: msgTypes["图片消息"], Percentage: ctPerc["图片消息"]}, - "语音消息": {Count: msgTypes["语音消息"], Percentage: ctPerc["语音消息"]}, - "文件消息": {Count: msgTypes["文件消息"], Percentage: ctPerc["文件消息"]}, - "链接消息": {Count: msgTypes["链接消息"], Percentage: ctPerc["链接消息"]}, - "XML消息": {Count: msgTypes["XML消息"], Percentage: ctPerc["XML消息"]}, - "好友验证消息": {Count: msgTypes["好友验证消息"], Percentage: ctPerc["好友验证消息"]}, - "好友推荐消息": {Count: msgTypes["好友推荐消息"], Percentage: ctPerc["好友推荐消息"]}, - "聊天表情": {Count: msgTypes["聊天表情"], Percentage: ctPerc["聊天表情"]}, - "位置消息": {Count: msgTypes["位置消息"], Percentage: ctPerc["位置消息"]}, - "音视频通话": {Count: msgTypes["音视频通话"], Percentage: ctPerc["音视频通话"]}, - "手机端操作消息": {Count: msgTypes["手机端操作消息"], Percentage: ctPerc["手机端操作消息"]}, - "系统通知": {Count: msgTypes["系统通知"], Percentage: ctPerc["系统通知"]}, - "撤回消息": {Count: msgTypes["撤回消息"], Percentage: ctPerc["撤回消息"]}, - }, - SourceChannels: map[string]SourceChannel{ - // 这里保留使用全量总数的比例(私聊+群聊 ≈ totalMsgs),无需再归一化 - "私聊数据": {Count: privateTotal, Percentage: pct(privateTotal)}, - "群聊数据": {Count: groupTotal, Percentage: pct(groupTotal)}, - }, - }, - Trend: trendEnhanced, - } - - // ===== Network(亲密度)===== - // 获取基础亲密度数据 - netNodes := make([]any, 0) - netLinks := make([]any, 0) + // ===== 关系网络(亲密度)===== + relationshipNodes := make([]RelationshipNode, 0) if s.db != nil && s.db.GetDB() != nil { if ibase, err := s.db.GetDB().IntimacyBase(); err == nil && len(ibase) > 0 { - // 忽略的系统/服务账号 skipIDs := map[string]struct{}{ "filehelper": {}, "weixin": {}, "notifymessage": {}, "fmessage": {}, } - // 取联系人信息用于展示名称与头像 contactMap := map[string]*model.Contact{} if clist, err := s.db.GetContacts("", 0, 0); err == nil && clist != nil { for _, ct := range clist.Items { @@ -883,7 +493,6 @@ func (s *Service) handleDashboard(c *gin.Context) { } } } - // 排序:按最近90天消息数、总消息数、过去7天发送数综合排序 type pair struct { k string v *model.IntimacyBase @@ -902,33 +511,14 @@ func (s *Service) handleDashboard(c *gin.Context) { } return ai.Past7DaysSentMsg > aj.Past7DaysSentMsg }) - // 只取前 N 个以避免图过大 - maxN := 100 + maxN := 24 if len(arr) < maxN { maxN = len(arr) } - // 计算有效最大分(排除自身与忽略账号)用于归一化 - effMax := 0.0 - for i := 0; i < len(arr); i++ { - k := arr[i].k - v := arr[i].v - if accountID != "" && k == accountID { - continue - } - if _, skip := skipIDs[k]; skip { - continue - } - raw := float64(v.Last90DaysMsg)*0.6 + float64(v.MsgCount)*0.3 + float64(v.Past7DaysSentMsg)*0.1 - if raw > effMax { - effMax = raw - } - } - // 节点构造 added := 0 for idx := 0; idx < len(arr) && added < maxN; idx++ { k := arr[idx].k v := arr[idx].v - // 过滤自身账户 if accountID != "" && k == accountID { continue } @@ -937,62 +527,100 @@ func (s *Service) handleDashboard(c *gin.Context) { } ct := contactMap[k] display := k - remark := "" if ct != nil { if strings.TrimSpace(ct.Remark) != "" { display = ct.Remark } else if strings.TrimSpace(ct.NickName) != "" { display = ct.NickName } - remark = ct.Remark } - size := v.MsgCount - if size < 1 { - size = 1 - } - // 简单亲密度评分:最近90天权重大 + 历史总量 + 过去7天发送 - rawScore := float64(v.Last90DaysMsg)*0.6 + float64(v.MsgCount)*0.3 + float64(v.Past7DaysSentMsg)*0.1 - // 归一化(0..1)与 0..100 - norm := 0.0 - if effMax > 0 { - norm = rawScore / effMax - } - if norm > 1 { - norm = 1 - } - intimacy := math.Round(norm * 100) - node := map[string]any{ - "id": k, - "name": display, - "type": "contact", - "size": size, - "messages": v.MsgCount, - "avatar": s.composeAvatarURL(k), - "intimacy": int(intimacy), - "wechatId": k, - "remark": remark, - } - netNodes = append(netNodes, node) - // 与当前用户连边 - strength := math.Round(norm*1000) / 1000 // 保留三位小数 - netLinks = append(netLinks, map[string]any{"source": "user", "target": k, "strength": strength}) + relationshipNodes = append(relationshipNodes, RelationshipNode{ + Name: display, + Type: "contact", + Messages: v.MsgCount, + Avatar: s.composeAvatarURL(k), + }) added++ } } } + others := totalMsgs - (msgTypes["文本消息"] + msgTypes["图片消息"] + msgTypes["语音消息"] + msgTypes["文件消息"] + msgTypes["链接消息"]) + if others < 0 { + others = 0 + } + defaultSelectedIndex := 0 + if len(groupList) == 0 { + defaultSelectedIndex = -1 + } + processingStatus := ProcessingStatus{} + if totalMsgs > 0 { + processingStatus.Processed = 100 + } + qualityMetrics := QualityMetrics{} + floatPtr := func(v float64) *float64 { return &v } + stringPtr := func(v string) *string { return &v } + vis := Visualization{ + Defaults: VisualizationDefaults{SelectedGroupIndex: defaultSelectedIndex}, + GroupAnalysis: GroupAnalysis{ + Title: "群聊分析", + Overview: GroupOverview{ + TotalGroups: totalGroups, + ActiveGroups: activeGroups, + TodayMessages: int(todayMessages), + WeeklyAvg: weeklyAvg, + MostActiveHour: mostActiveHour, + }, + ContentAnalysis: ContentAnalysis{ + Text: msgTypes["文本消息"], + Images: msgTypes["图片消息"], + Voice: msgTypes["语音消息"], + Files: msgTypes["文件消息"], + Links: msgTypes["链接消息"], + Others: others, + }, + GroupList: groupList, + }, + DataTypeAnalysis: DataTypeAnalysis{ + Title: "数据类型统计", + ContentTypes: map[string]ContentTypeStats{ + "文本消息": {Count: msgTypes["文本消息"], Percentage: ctPerc["文本消息"]}, + "图片消息": {Count: msgTypes["图片消息"], Percentage: ctPerc["图片消息"]}, + "语音消息": {Count: msgTypes["语音消息"], Percentage: ctPerc["语音消息"]}, + "文件消息": {Count: msgTypes["文件消息"], Percentage: ctPerc["文件消息"]}, + "链接消息": {Count: msgTypes["链接消息"], Percentage: ctPerc["链接消息"], SizeMB: floatPtr(0), Trend: stringPtr("")}, + "XML消息": {Count: msgTypes["XML消息"], Percentage: ctPerc["XML消息"]}, + "好友验证消息": {Count: msgTypes["好友验证消息"], Percentage: ctPerc["好友验证消息"]}, + "好友推荐消息": {Count: msgTypes["好友推荐消息"], Percentage: ctPerc["好友推荐消息"]}, + "聊天表情": {Count: msgTypes["聊天表情"], Percentage: ctPerc["聊天表情"]}, + "位置消息": {Count: msgTypes["位置消息"], Percentage: ctPerc["位置消息"]}, + "音视频通话": {Count: msgTypes["音视频通话"], Percentage: ctPerc["音视频通话"]}, + "手机端操作消息": {Count: msgTypes["手机端操作消息"], Percentage: ctPerc["手机端操作消息"]}, + "系统通知": {Count: msgTypes["系统通知"], Percentage: ctPerc["系统通知"]}, + "撤回消息": {Count: msgTypes["撤回消息"], Percentage: ctPerc["撤回消息"]}, + }, + SourceChannels: map[string]SourceChannel{ + "私聊数据": {Count: privateTotal, Percentage: pct(privateTotal)}, + "群聊数据": {Count: groupTotal, Percentage: pct(groupTotal)}, + }, + ProcessingStatus: processingStatus, + QualityMetrics: qualityMetrics, + PieGradient: "#3b82f6 0deg 180deg, #10b981 180deg 270deg, #f59e0b 270deg 315deg, #ef4444 315deg 360deg", + }, + RelationshipNetwork: RelationshipNetwork{Nodes: relationshipNodes}, + } + resp := Dashboard{ Overview: Overview{ User: currentUser, DBStats: DBStats{DbSizeMB: roundMB(dbSize), DirSizeMB: roundMB(dirSize)}, - MsgStats: MsgStats{TotalMsgs: gstats.Total, SentMsgs: gstats.Sent, ReceivedMsgs: gstats.Received, UniqueMsgTypes: len(gstats.ByType)}, + MsgStats: MsgStats{TotalMsgs: gstats.Total, SentMsgs: gstats.Sent, ReceivedMsgs: gstats.Received, UniqueMsgTypes: uniqueTypes}, MsgTypes: msgTypes, - Groups: ogroups, + Groups: overviewGroups, Timeline: Timeline{Earliest: gstats.EarliestUnix, Latest: gstats.LatestUnix, Duration: durationDays}, Migrations: []Migration{}, }, Visualization: vis, - Network: Network{Nodes: netNodes, Links: netLinks}, } // ===== 持久化 dashboard (单一文件)===== @@ -1046,27 +674,6 @@ func roundMB(bytes int64) float64 { return float64(int(mb*100+0.5)) / 100.0 } -func diffDays(a, b time.Time) int { - if a.IsZero() || b.IsZero() { - return 0 - } - if b.Before(a) { - a, b = b, a - } - d := b.Sub(a).Hours() / 24.0 - if d < 0 { - return 0 - } - return int(d + 0.5) -} - -func formatTime(t time.Time) string { - if t.IsZero() { - return "" - } - return t.Format("2006-01-02 15:04:05") -} - // safeDirSize walks a directory and sums file sizes; returns 0 on error. func safeDirSize(path string) int64 { var total int64 @@ -1516,11 +1123,11 @@ func (s *Service) handleSessions(c *gin.Context) { } } -// handleDiary 返回最近N(24/48/72)小时内“我”发送的消息,按 talker 分组。 -// GET /api/v1/diary?hours=(24|48|72)&format=(html|json|csv|text) +// handleDiary 返回指定日期内“我”参与的消息(日记),按 talker 分组。 +// GET /api/v1/diary?date=YYYY-MM-DD&format=(html|json|csv|text) func (s *Service) handleDiary(c *gin.Context) { q := struct { - Hours int `form:"hours"` + Date string `form:"date"` Talker string `form:"talker"` Format string `form:"format"` }{} @@ -1528,16 +1135,23 @@ func (s *Service) handleDiary(c *gin.Context) { errors.Err(c, err) return } - // 默认24h,仅允许 24/48/72 - hours := q.Hours - if hours == 0 { - hours = 24 + + dateStr := strings.TrimSpace(q.Date) + if dateStr == "" { + dateStr = time.Now().Format("2006-01-02") } - if hours != 24 && hours != 48 && hours != 72 { - hours = 24 + + parsed, err := time.ParseInLocation("2006-01-02", dateStr, time.Local) + if err != nil { + errors.Err(c, errors.InvalidArg("date")) + return } - end := time.Now() - start := end.Add(-time.Duration(hours) * time.Hour) + start := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, parsed.Location()) + end := start.Add(24*time.Hour - time.Nanosecond) + + startDisplay := start.Format("2006-01-02 15:04:05") + endDisplay := end.Format("2006-01-02 15:04:05") + heading := fmt.Sprintf("%s 的聊天日记(%s ~ %s)", start.Format("2006-01-02"), startDisplay, endDisplay) // 获取会话(可选 talker 过滤) sessionsResp, err := s.db.GetSessions(q.Talker, 0, 0) @@ -1576,7 +1190,7 @@ func (s *Service) handleDiary(c *gin.Context) { case "html": c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8") c.Writer.WriteString(`
- 查询最近N小时我参与过的会话全部消息(可选:24h、48h、72h)。GET /api/v1/diary