diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index e90cbc2..be035e3 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -5289,12 +5289,24 @@ func TestMailStatsQuotaAndCleanupIsolation(t *testing.T) { t.Fatalf("alice login code=%d", code) } var stats MailStats - if code := alice.do("GET", "/api/me/stats?mailboxId="+aliceMB.ID, nil, &stats); code != http.StatusOK { + if code := alice.do("GET", "/api/me/stats?mailboxId="+aliceMB.ID+"&days=7", nil, &stats); code != http.StatusOK { t.Fatalf("stats code=%d stats=%+v", code, stats) } if stats.QuotaBytes != int64(aliceMB.QuotaMB)*1024*1024 || stats.AttachmentBytes == 0 || stats.QuotaUsedPct <= 0 { t.Fatalf("stats quota/attachment not populated: %+v", stats) } + if stats.TotalIncoming != 1 || stats.TotalOutgoing != 0 || stats.AverageMessageBytes <= 0 { + t.Fatalf("stats message totals not populated: %+v", stats) + } + if len(stats.Trend) != 7 || stats.Trend[len(stats.Trend)-1].Incoming != 1 { + t.Fatalf("stats trend not populated: %+v", stats.Trend) + } + if !mailStatsDistributionHas(stats.Distribution, "trash", 1) || !mailStatsDistributionHas(stats.Distribution, "attachments", 1) { + t.Fatalf("stats distribution not populated: %+v", stats.Distribution) + } + if len(stats.TopContacts) == 0 || stats.TopContacts[0].Email != "sender@example.test" || stats.TopContacts[0].Count != 1 { + t.Fatalf("stats top contacts not populated: %+v", stats.TopContacts) + } var cleanup struct { OK bool `json:"ok"` Affected int64 `json:"affected"` @@ -5317,6 +5329,15 @@ func TestMailStatsQuotaAndCleanupIsolation(t *testing.T) { } } +func mailStatsDistributionHas(items []MailStatsDistributionItem, key string, count int64) bool { + for _, item := range items { + if item.Key == key && item.Count == count { + return true + } + } + return false +} + func mustDefaultDomainID(t *testing.T, a *App) string { t.Helper() var id string diff --git a/apps/api/internal/app/personal_handlers.go b/apps/api/internal/app/personal_handlers.go index 9b8e824..896566d 100644 --- a/apps/api/internal/app/personal_handlers.go +++ b/apps/api/internal/app/personal_handlers.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "net/http" + "sort" "strconv" "strings" "time" @@ -641,6 +642,7 @@ func (a *App) handleDeleteBlockedSender(w http.ResponseWriter, r *http.Request) func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) { user := currentUser(r) mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId")) + rangeDays := mailStatsRangeDays(r.URL.Query().Get("days")) args := []any{user.ID} where := `mb.user_id=?` if mailboxID != "" && !isAllMailboxID(mailboxID) { @@ -651,17 +653,54 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) { where += ` AND mb.id=?` args = append(args, mailboxID) } - stats := MailStats{ByFolder: []MailStatsFolderCount{}} - row := a.db.QueryRowContext(r.Context(), `SELECT COUNT(m.id),COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),COALESCE(SUM(CASE WHEN m.is_starred=1 THEN 1 ELSE 0 END),0),COALESCE(SUM(m.size_bytes),0) - FROM mailboxes mb LEFT JOIN messages m ON m.mailbox_id=mb.id WHERE `+where, args...) - if err := row.Scan(&stats.TotalMessages, &stats.UnreadMessages, &stats.StarredMessages, &stats.StorageBytes); err != nil { + now := a.now().UTC() + stats := MailStats{ + ByFolder: []MailStatsFolderCount{}, + Trend: emptyMailStatsTrend(now, rangeDays), + Distribution: []MailStatsDistributionItem{}, + TopContacts: []MailStatsContact{}, + } + row := a.db.QueryRowContext(r.Context(), `SELECT COUNT(m.id), + COALESCE(SUM(CASE WHEN f.role NOT IN ('sent','drafts') THEN 1 ELSE 0 END),0), + COALESCE(SUM(CASE WHEN f.role='sent' THEN 1 ELSE 0 END),0), + COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0), + COALESCE(SUM(CASE WHEN f.role='drafts' THEN 1 ELSE 0 END),0), + COALESCE(SUM(CASE WHEN m.is_starred=1 THEN 1 ELSE 0 END),0), + COALESCE(SUM(m.size_bytes),0) + FROM mailboxes mb + LEFT JOIN messages m ON m.mailbox_id=mb.id + LEFT JOIN folders f ON f.id=m.folder_id + WHERE `+where, args...) + if err := row.Scan(&stats.TotalMessages, &stats.TotalIncoming, &stats.TotalOutgoing, &stats.UnreadMessages, &stats.DraftMessages, &stats.StarredMessages, &stats.StorageBytes); err != nil { respondError(w, http.StatusInternalServerError, "failed to load stats") return } + if stats.TotalMessages > 0 { + stats.AverageMessageBytes = stats.StorageBytes / stats.TotalMessages + } if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(a.id),COALESCE(SUM(a.size_bytes),0) FROM attachments a JOIN messages m ON m.id=a.message_id JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...).Scan(&stats.AttachmentCount, &stats.AttachmentBytes); err != nil { respondError(w, http.StatusInternalServerError, "failed to load attachment stats") return } + var attachmentMessageCount int64 + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(m.id) FROM mailboxes mb JOIN messages m ON m.mailbox_id=mb.id WHERE `+where+` AND m.has_attachments=1`, args...).Scan(&attachmentMessageCount); err != nil { + respondError(w, http.StatusInternalServerError, "failed to load attachment message stats") + return + } + todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano) + todayArgs := append(append([]any{}, args...), todayStart) + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(m.id) + FROM mailboxes mb JOIN messages m ON m.mailbox_id=mb.id JOIN folders f ON f.id=m.folder_id + WHERE `+where+` AND f.role='sent' AND m.sent_at>=?`, todayArgs...).Scan(&stats.TodayOutgoing); err != nil { + respondError(w, http.StatusInternalServerError, "failed to load today stats") + return + } + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(sq.id) + FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id + WHERE `+where+` AND sq.status='failed'`, args...).Scan(&stats.FailedSends); err != nil { + respondError(w, http.StatusInternalServerError, "failed to load send queue stats") + return + } if mailboxID != "" && !isAllMailboxID(mailboxID) { var quotaMB int64 if err := a.db.QueryRowContext(r.Context(), `SELECT quota_mb FROM mailboxes WHERE id=? AND user_id=?`, mailboxID, user.ID).Scan("aMB); err != nil { @@ -672,6 +711,16 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) { if stats.QuotaBytes > 0 { stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100 } + } else { + var quotaMB int64 + if err := a.db.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(mb.quota_mb),0) FROM mailboxes mb WHERE `+where, args...).Scan("aMB); err != nil { + respondError(w, http.StatusInternalServerError, "failed to load quota") + return + } + stats.QuotaBytes = quotaMB * 1024 * 1024 + if stats.QuotaBytes > 0 { + stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100 + } } rows, err := a.db.QueryContext(r.Context(), `SELECT f.name,f.role,COUNT(m.id),COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),COALESCE(SUM(m.size_bytes),0) FROM mailboxes mb JOIN folders f ON f.mailbox_id=mb.id LEFT JOIN messages m ON m.folder_id=f.id @@ -689,9 +738,165 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) { } stats.ByFolder = append(stats.ByFolder, item) } + if err := rows.Err(); err != nil { + respondError(w, http.StatusInternalServerError, "failed to scan folder stats") + return + } + stats.Distribution = mailStatsDistribution(stats.ByFolder, attachmentMessageCount, stats.StarredMessages) + if err := a.loadMailStatsTrend(r.Context(), where, args, rangeDays, stats.Trend); err != nil { + respondError(w, http.StatusInternalServerError, "failed to load trend stats") + return + } + topContacts, err := a.mailStatsTopContacts(r.Context(), where, args) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load contact stats") + return + } + stats.TopContacts = topContacts respondJSON(w, http.StatusOK, stats) } +func mailStatsRangeDays(raw string) int { + days, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || days <= 0 { + return 30 + } + switch days { + case 7, 30, 90, 365: + return days + default: + if days < 7 { + return 7 + } + if days > 365 { + return 365 + } + return days + } +} + +func emptyMailStatsTrend(now time.Time, days int) []MailStatsTrendPoint { + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + points := make([]MailStatsTrendPoint, 0, days) + for i := days - 1; i >= 0; i-- { + points = append(points, MailStatsTrendPoint{Date: today.AddDate(0, 0, -i).Format("2006-01-02")}) + } + return points +} + +func mailStatsDistribution(rows []MailStatsFolderCount, attachmentMessages, starred int64) []MailStatsDistributionItem { + roles := map[string]int64{} + for _, row := range rows { + roles[strings.ToLower(row.Role)] += row.Count + } + return []MailStatsDistributionItem{ + {Key: "inbox", Label: "收件箱", Count: roles["inbox"]}, + {Key: "archive", Label: "已归档", Count: roles["archive"]}, + {Key: "spam", Label: "垃圾邮件", Count: roles["spam"]}, + {Key: "trash", Label: "已删除", Count: roles["trash"]}, + {Key: "attachments", Label: "有附件", Count: attachmentMessages}, + {Key: "starred", Label: "已加旗标", Count: starred}, + } +} + +func (a *App) loadMailStatsTrend(ctx context.Context, where string, args []any, days int, trend []MailStatsTrendPoint) error { + start := "" + if len(trend) > 0 { + start = trend[0].Date + "T00:00:00Z" + } + queryArgs := append(append([]any{}, args...), start) + rows, err := a.db.QueryContext(ctx, `SELECT substr(CASE WHEN f.role='sent' THEN m.sent_at ELSE m.received_at END, 1, 10), + COALESCE(SUM(CASE WHEN f.role='sent' THEN 0 ELSE 1 END),0), + COALESCE(SUM(CASE WHEN f.role='sent' THEN 1 ELSE 0 END),0) + FROM mailboxes mb JOIN messages m ON m.mailbox_id=mb.id JOIN folders f ON f.id=m.folder_id + WHERE `+where+` AND f.role<>'drafts' AND (CASE WHEN f.role='sent' THEN m.sent_at ELSE m.received_at END)>=? + GROUP BY 1`, queryArgs...) + if err != nil { + return err + } + defer rows.Close() + byDate := map[string]*MailStatsTrendPoint{} + for i := range trend { + byDate[trend[i].Date] = &trend[i] + } + for rows.Next() { + var date string + var incoming, outgoing int64 + if err := rows.Scan(&date, &incoming, &outgoing); err != nil { + return err + } + if point := byDate[date]; point != nil { + point.Incoming = incoming + point.Outgoing = outgoing + } + } + return rows.Err() +} + +func (a *App) mailStatsTopContacts(ctx context.Context, where string, args []any) ([]MailStatsContact, error) { + rows, err := a.db.QueryContext(ctx, `SELECT f.role,m.from_addr,m.to_addrs,m.cc_addrs,m.bcc_addrs + FROM mailboxes mb JOIN messages m ON m.mailbox_id=mb.id JOIN folders f ON f.id=m.folder_id + WHERE `+where+` + ORDER BY m.received_at DESC LIMIT 2000`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + counts := map[string]int64{} + for rows.Next() { + var role, from, toJSON, ccJSON, bccJSON string + if err := rows.Scan(&role, &from, &toJSON, &ccJSON, &bccJSON); err != nil { + return nil, err + } + if strings.EqualFold(role, "sent") { + for _, email := range append(append(mailStatsEmailList(toJSON), mailStatsEmailList(ccJSON)...), mailStatsEmailList(bccJSON)...) { + if email != "" { + counts[email]++ + } + } + continue + } + if email := normalizeEmail(from); email != "" && strings.Contains(email, "@") { + counts[email]++ + } + } + if err := rows.Err(); err != nil { + return nil, err + } + items := make([]MailStatsContact, 0, len(counts)) + for email, count := range counts { + items = append(items, MailStatsContact{Email: email, Count: count}) + } + sort.Slice(items, func(i, j int) bool { + if items[i].Count == items[j].Count { + return items[i].Email < items[j].Email + } + return items[i].Count > items[j].Count + }) + if len(items) > 10 { + items = items[:10] + } + return items, nil +} + +func mailStatsEmailList(raw string) []string { + var values []string + if err := json.Unmarshal([]byte(raw), &values); err != nil { + return nil + } + out := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + email := normalizeEmail(value) + if email == "" || !strings.Contains(email, "@") || seen[email] { + continue + } + seen[email] = true + out = append(out, email) + } + return out +} + func (a *App) handleMailCleanup(w http.ResponseWriter, r *http.Request) { var req struct { MailboxID string `json:"mailboxId"` diff --git a/apps/api/internal/app/types.go b/apps/api/internal/app/types.go index 005ec12..9efcc67 100644 --- a/apps/api/internal/app/types.go +++ b/apps/api/internal/app/types.go @@ -237,15 +237,24 @@ type BlockedSender struct { } type MailStats struct { - TotalMessages int64 `json:"totalMessages"` - UnreadMessages int64 `json:"unreadMessages"` - StarredMessages int64 `json:"starredMessages"` - AttachmentCount int64 `json:"attachmentCount"` - AttachmentBytes int64 `json:"attachmentBytes"` - StorageBytes int64 `json:"storageBytes"` - QuotaBytes int64 `json:"quotaBytes"` - QuotaUsedPct float64 `json:"quotaUsedPct"` - ByFolder []MailStatsFolderCount `json:"byFolder"` + TotalMessages int64 `json:"totalMessages"` + TotalIncoming int64 `json:"totalIncoming"` + TotalOutgoing int64 `json:"totalOutgoing"` + UnreadMessages int64 `json:"unreadMessages"` + TodayOutgoing int64 `json:"todayOutgoing"` + DraftMessages int64 `json:"draftMessages"` + FailedSends int64 `json:"failedSends"` + StarredMessages int64 `json:"starredMessages"` + AttachmentCount int64 `json:"attachmentCount"` + AttachmentBytes int64 `json:"attachmentBytes"` + StorageBytes int64 `json:"storageBytes"` + QuotaBytes int64 `json:"quotaBytes"` + QuotaUsedPct float64 `json:"quotaUsedPct"` + AverageMessageBytes int64 `json:"averageMessageBytes"` + ByFolder []MailStatsFolderCount `json:"byFolder"` + Trend []MailStatsTrendPoint `json:"trend"` + Distribution []MailStatsDistributionItem `json:"distribution"` + TopContacts []MailStatsContact `json:"topContacts"` } type MailStatsFolderCount struct { @@ -256,6 +265,23 @@ type MailStatsFolderCount struct { Bytes int64 `json:"bytes"` } +type MailStatsTrendPoint struct { + Date string `json:"date"` + Incoming int64 `json:"incoming"` + Outgoing int64 `json:"outgoing"` +} + +type MailStatsDistributionItem struct { + Key string `json:"key"` + Label string `json:"label"` + Count int64 `json:"count"` +} + +type MailStatsContact struct { + Email string `json:"email"` + Count int64 `json:"count"` +} + type ExternalIMAPAccount struct { ID string `json:"id"` UserID string `json:"userId,omitempty"` diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index 131304b..a111a76 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -124,7 +124,26 @@ export type MailRuleCondition = { field?: MailRuleConditionField; operator?: Mai export type MailRuleAction = { type: "archive" | "trash" | "star" | "mark-read" | "label" | "move" | "forward"; value?: string; labelId?: string } export type MailRule = { id: string; mailboxId: string; name: string; matchMode: "all" | "any"; conditions: MailRuleCondition[]; actions: MailRuleAction[]; applyToExisting: boolean; stopProcessing: boolean; fromContains: string; subjectContains: string; action: "archive" | "trash" | "star" | "mark-read" | "label" | "move" | "forward"; enabled: boolean; createdAt: string; appliedExistingCount?: number } export type BlockedSender = { id: string; mailboxId: string; email: string; reason: string; createdAt: string } -export type MailStats = { totalMessages: number; unreadMessages: number; starredMessages: number; attachmentCount: number; attachmentBytes: number; storageBytes: number; quotaBytes: number; quotaUsedPct: number; byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[] } +export type MailStats = { + totalMessages: number + totalIncoming: number + totalOutgoing: number + unreadMessages: number + todayOutgoing: number + draftMessages: number + failedSends: number + starredMessages: number + attachmentCount: number + attachmentBytes: number + storageBytes: number + quotaBytes: number + quotaUsedPct: number + averageMessageBytes: number + byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[] + trend: { date: string; incoming: number; outgoing: number }[] + distribution: { key: string; label: string; count: number }[] + topContacts: { email: string; count: number }[] +} export type ForwardingVerifiedEmail = { id: string email: string diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 5667bfd..ca8a525 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -99,7 +99,13 @@ export const api = { blockedSenders: () => request>("/api/me/blocked-senders"), createBlockedSender: (payload: { mailboxId: string; email: string; reason: string }) => request("/api/me/blocked-senders", { method: "POST", body: JSON.stringify(payload) }), deleteBlockedSender: (id: string) => request<{ ok: boolean }>(`/api/me/blocked-senders/${id}`, { method: "DELETE" }), - mailStats: (mailboxId?: string) => request(`/api/me/stats${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`), + mailStats: (mailboxId?: string, days?: number) => { + const query = new URLSearchParams() + if (mailboxId) query.set("mailboxId", mailboxId) + if (days) query.set("days", String(days)) + const suffix = query.toString() + return request(`/api/me/stats${suffix ? `?${suffix}` : ""}`) + }, cleanupMail: (payload: { mailboxId: string; target: "empty-trash" | "empty-spam" | "archive-read-inbox" }) => request<{ ok: boolean; affected: number }>("/api/me/cleanup", { method: "POST", body: JSON.stringify(payload) }), mailboxApplyOptions: () => request("/api/me/mailbox-apply-options"), applyMailbox: (payload: { domainId: string; localPart: string; displayName: string }) => request("/api/me/mailboxes/apply", { method: "POST", body: JSON.stringify(payload) }), diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx index 85c23d7..0db4a49 100644 --- a/apps/web/src/pages/profile.tsx +++ b/apps/web/src/pages/profile.tsx @@ -1,7 +1,7 @@ import * as React from "react" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useNavigate, useSearchParams } from "react-router-dom" -import { ArrowLeft, BarChart3, Ban, BookOpen, ChevronDown, Clock3, Code2, Contact, Copy, ExternalLink, Image, Info, KeyRound, Laptop, Link2, LogOut, Mail, MailCheck, MailX, MessageSquare, Moon, PanelLeftOpen, PencilLine, Plus, RefreshCcw, Search, SendHorizontal, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, X } from "lucide-react" +import { Archive, ArrowLeft, BarChart3, Ban, BookOpen, ChevronDown, Clock3, Code2, Contact, Copy, ExternalLink, HardDrive, Image, Info, KeyRound, Laptop, Link2, LogOut, Mail, MailCheck, MailX, MessageSquare, Moon, PanelLeftOpen, PencilLine, Plus, RefreshCcw, Search, SendHorizontal, Settings, ShieldCheck, SlidersHorizontal, Star, Sun, Trash2, Users, X } from "lucide-react" import { QRCodeSVG } from "qrcode.react" import { api, APIToken, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapStorageMode, ExternalImapSyncRun, ExternalImapTlsMode, ForwardingSettings, ForwardingVerifiedEmail, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api" import { cn, formatBytes } from "@/lib/utils" @@ -62,6 +62,7 @@ export function ProfilePage() { const passwordFormRef = React.useRef(null) const twoFactorFormRef = React.useRef(null) const [mailboxId, setMailboxId] = React.useState(() => localStorage.getItem("lanqin:selected-mailbox") || "") + const [statsRangeDays, setStatsRangeDays] = React.useState(30) const [darkMode, setDarkMode] = React.useState(getInitialTheme) const [displayMode, setDisplayMode] = useDisplayMode() const [blockedMailboxId, setBlockedMailboxId] = React.useState("all") @@ -120,7 +121,7 @@ export function ProfilePage() { const externalRunFolders = useQuery({ queryKey: ["external-imap-run-folders", externalRunAccountId], queryFn: () => api.externalFolders(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled }) const externalSyncRuns = useQuery({ queryKey: ["external-imap-sync-runs", externalRunAccountId], queryFn: () => api.externalImapSyncRuns(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled }) const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && (canReadMail || canManageLabels || canManageRules) }) - const stats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId && canViewStats }) + const stats = useQuery({ queryKey: ["mail-stats", activeMailboxId, statsRangeDays], queryFn: () => api.mailStats(activeMailboxId, statsRangeDays), enabled: !!activeMailboxId && canViewStats }) const profile = useMutation({ mutationFn: (form: FormData) => api.updateProfile({ displayName: String(form.get("displayName") || "") }), @@ -454,7 +455,7 @@ export function ProfilePage() { if (tab === "cleanupQueue") return if (tab === "rules") return createRule.mutate(payload)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} /> if (tab === "blocked") return f.role === "spam")?.count || 0 : 0} onMailboxChange={setBlockedMailboxId} onCreate={(form) => createBlocked.mutate(form)} onDelete={(id) => deleteBlocked.mutate(id)} pending={createBlocked.isPending} /> - if (tab === "stats") return stats.refetch()} /> + if (tab === "stats") return stats.refetch()} /> if (tab === "feedback") return if (tab === "apiTokens") return createApiToken.mutateAsync(payload)} onUpdate={(id, payload) => updateApiToken.mutate({ id, payload })} onDelete={(id) => deleteApiToken.mutate(id)} onCopy={copy} /> return null @@ -2971,27 +2972,41 @@ function BlockedSection({ items, mailboxes, mailboxId, spamCount, onMailboxChang ) } -function StatsSection({ stats, mailbox, onRefresh }: { stats?: MailStats; mailbox?: Mailbox; onRefresh: () => void }) { - const [range, setRange] = React.useState("30") +function StatsSection({ stats, mailbox, rangeDays, onRangeChange, onRefresh }: { stats?: MailStats; mailbox?: Mailbox; rangeDays: number; onRangeChange: (days: number) => void; onRefresh: () => void }) { const quotaLabel = stats?.quotaBytes ? `${formatBytes(stats.storageBytes || 0)} / ${formatBytes(stats.quotaBytes)}` : formatBytes(stats?.storageBytes || 0) const quotaPct = Math.min(stats?.quotaUsedPct || 0, 100) + const primaryCards = [ + { label: "总收件", value: stats?.totalIncoming || 0, icon: , tone: "bg-blue-50 text-blue-600" }, + { label: "总发件", value: stats?.totalOutgoing || 0, icon: , tone: "bg-emerald-50 text-emerald-600" }, + { label: "未读邮件", value: stats?.unreadMessages || 0, icon: , tone: "bg-amber-50 text-amber-600" }, + { label: "存储用量", value: quotaLabel, detail: stats?.quotaBytes ? `${quotaPct.toFixed(0)}%` : "不限", icon: , tone: "bg-slate-100 text-slate-700" }, + ] + const secondaryStats = [ + { label: "今日发件", value: stats?.todayOutgoing || 0 }, + { label: "草稿", value: stats?.draftMessages || 0 }, + { label: "发送失败", value: stats?.failedSends || 0 }, + { label: "平均邮件大小", value: formatBytes(stats?.averageMessageBytes || 0) }, + ] return (
-
当前统计:{mailbox?.address || "未选择邮箱"}
+
+

查看邮件收发趋势、分布情况和常用联系人。

+ {mailbox &&

{mailbox.address}

} +
{[ - ["7", "7天"], - ["30", "30天"], - ["90", "90天"], - ["365", "365天"], + [7, "7天"], + [30, "30天"], + [90, "90天"], + [365, "365天"], ].map(([value, label]) => ( @@ -3000,22 +3015,171 @@ function StatsSection({ stats, mailbox, onRefresh }: { stats?: MailStats; mailbo
- -
- - +
+ {primaryCards.map((card) => ( +
+
+
{card.icon}
+ {card.detail && {card.detail}} +
+
{card.value}
+
{card.label}
+
+ ))} +
+
+ {secondaryStats.map((item) => ( +
+
{item.value}
+
{item.label}
+
+ ))} +
+ + + +
+ + -
-
{quotaLabel}
-
{stats?.quotaBytes ? `${quotaPct.toFixed(0)}%` : "不限"}
-
-
-
-
-

{quotaPct >= 90 ? "存储容量接近上限,请及时清理。" : "存储容量使用正常。"}

+
+ + + +
+ ) +} + +function StatsTrendChart({ points }: { points: MailStats["trend"] }) { + const data = points.length ? points : [{ date: "", incoming: 0, outgoing: 0 }] + const maxValue = Math.max(...data.flatMap((item) => [item.incoming, item.outgoing]), 1) + const width = 520 + const height = 210 + const padding = { top: 18, right: 14, bottom: 32, left: 34 } + const plotWidth = width - padding.left - padding.right + const plotHeight = height - padding.top - padding.bottom + const xFor = (index: number) => padding.left + (data.length === 1 ? plotWidth / 2 : (index / (data.length - 1)) * plotWidth) + const yFor = (value: number) => padding.top + plotHeight - (value / maxValue) * plotHeight + const pathFor = (key: "incoming" | "outgoing") => data.map((item, index) => `${index === 0 ? "M" : "L"} ${xFor(index).toFixed(1)} ${yFor(item[key]).toFixed(1)}`).join(" ") + const ticks = trendTicks(data) + return ( +
+
+ 收件 + 发件 +
+ + {[0, 0.25, 0.5, 0.75, 1].map((step) => { + const y = padding.top + plotHeight * step + return + })} + + + {data.map((item, index) => ( + + + + + ))} + {maxValue} + 0 + {ticks.map((tick) => ( + {tick.label} + ))} + +
+ ) +} + +function trendTicks(data: MailStats["trend"]) { + if (data.length === 0) return [] + const count = Math.min(5, data.length) + const seen = new Set() + const ticks: { index: number; label: string }[] = [] + for (let i = 0; i < count; i++) { + const index = count === 1 ? 0 : Math.round((i / (count - 1)) * (data.length - 1)) + if (seen.has(index)) continue + seen.add(index) + ticks.push({ index, label: formatStatsDate(data[index]?.date || "") }) + } + return ticks +} + +function formatStatsDate(value: string) { + if (!value) return "" + const [, month, day] = value.split("-") + return month && day ? `${month}-${day}` : value +} + +function StatsDistribution({ items }: { items: MailStats["distribution"] }) { + const rows = items.length ? items : [ + { key: "inbox", label: "收件箱", count: 0 }, + { key: "archive", label: "已归档", count: 0 }, + { key: "spam", label: "垃圾邮件", count: 0 }, + { key: "trash", label: "已删除", count: 0 }, + { key: "attachments", label: "有附件", count: 0 }, + { key: "starred", label: "已加旗标", count: 0 }, + ] + const maxCount = Math.max(...rows.map((row) => row.count), 1) + return ( +
+ {rows.map((row) => ( +
+
+
+ {distributionIcon(row.key)} + {row.label} +
+ {row.count} +
+
+
+
+
+ ))} +
+ ) +} + +function distributionIcon(key: string) { + const cls = "h-4 w-4" + if (key === "archive") return + if (key === "spam") return + if (key === "trash") return + if (key === "attachments") return + if (key === "starred") return + return +} + +function StatsStorage({ quotaLabel, quotaPct, hasQuota }: { quotaLabel: string; quotaPct: number; hasQuota: boolean }) { + return ( +
+
+
{quotaLabel}
+
{hasQuota ? `${quotaPct.toFixed(0)}%` : "不限"}
+
+
+
+
+

{quotaPct >= 90 ? "存储容量接近上限,请及时清理。" : "存储容量使用正常。"}

+
+ ) +} + +function StatsContacts({ contacts }: { contacts: MailStats["topContacts"] }) { + if (contacts.length === 0) return } text="暂无常用联系人" description="有邮件往来后会显示联系人排行" /> + return ( +
+ {contacts.map((item, index) => ( +
+
{index + 1}
+
{item.email}
+ {item.count} 封 +
+ ))}
) }