fix: align mail statistics dashboard
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -99,7 +99,13 @@ export const api = {
|
||||
blockedSenders: () => request<ListResponse<BlockedSender>>("/api/me/blocked-senders"),
|
||||
createBlockedSender: (payload: { mailboxId: string; email: string; reason: string }) => request<BlockedSender>("/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<MailStats>(`/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<MailStats>(`/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<MailboxApplyOptions>("/api/me/mailbox-apply-options"),
|
||||
applyMailbox: (payload: { domainId: string; localPart: string; displayName: string }) => request<Mailbox>("/api/me/mailboxes/apply", { method: "POST", body: JSON.stringify(payload) }),
|
||||
|
||||
+188
-24
@@ -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<HTMLFormElement>(null)
|
||||
const twoFactorFormRef = React.useRef<HTMLFormElement>(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 <CleanupQueueSection mailbox={selectedMailbox} stats={canViewStats ? stats.data : undefined} />
|
||||
if (tab === "rules") return <RulesSection items={rules.data?.items || []} mailboxes={mailboxes.data?.items || []} labels={labels.data?.items || []} open={ruleDialogOpen} onOpenChange={setRuleDialogOpen} onCreate={(payload) => createRule.mutate(payload)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} />
|
||||
if (tab === "blocked") return <BlockedSection items={blocked.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={blockedMailboxId} spamCount={canViewStats ? stats.data?.byFolder.find((f) => 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 <StatsSection stats={stats.data} mailbox={selectedMailbox} onRefresh={() => stats.refetch()} />
|
||||
if (tab === "stats") return <StatsSection stats={stats.data} mailbox={selectedMailbox} rangeDays={statsRangeDays} onRangeChange={setStatsRangeDays} onRefresh={() => stats.refetch()} />
|
||||
if (tab === "feedback") return <FeedbackSection />
|
||||
if (tab === "apiTokens") return <ApiTokensSection items={apiTokens.data?.items || []} loading={apiTokens.isLoading} pending={createApiToken.isPending || updateApiToken.isPending || deleteApiToken.isPending} onCreate={(payload) => 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: <Mail className="h-4 w-4" />, tone: "bg-blue-50 text-blue-600" },
|
||||
{ label: "总发件", value: stats?.totalOutgoing || 0, icon: <SendHorizontal className="h-4 w-4" />, tone: "bg-emerald-50 text-emerald-600" },
|
||||
{ label: "未读邮件", value: stats?.unreadMessages || 0, icon: <MailCheck className="h-4 w-4" />, tone: "bg-amber-50 text-amber-600" },
|
||||
{ label: "存储用量", value: quotaLabel, detail: stats?.quotaBytes ? `${quotaPct.toFixed(0)}%` : "不限", icon: <HardDrive className="h-4 w-4" />, 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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-xs font-medium text-muted-foreground">当前统计:{mailbox?.address || "未选择邮箱"}</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm leading-6 text-muted-foreground">查看邮件收发趋势、分布情况和常用联系人。</p>
|
||||
{mailbox && <p className="mt-0.5 truncate text-xs text-muted-foreground/80">{mailbox.address}</p>}
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
<div className="grid grid-cols-4 rounded-md border bg-background p-0.5 sm:flex">
|
||||
{[
|
||||
["7", "7天"],
|
||||
["30", "30天"],
|
||||
["90", "90天"],
|
||||
["365", "365天"],
|
||||
[7, "7天"],
|
||||
[30, "30天"],
|
||||
[90, "90天"],
|
||||
[365, "365天"],
|
||||
].map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={cn("h-7 rounded px-2.5 text-xs font-medium transition-colors", range === value ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground")}
|
||||
onClick={() => setRange(value)}
|
||||
className={cn("h-7 rounded px-2.5 text-xs font-medium transition-colors", rangeDays === value ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground")}
|
||||
onClick={() => onRangeChange(Number(value))}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
@@ -3000,22 +3015,171 @@ function StatsSection({ stats, mailbox, onRefresh }: { stats?: MailStats; mailbo
|
||||
<Button variant="outline" size="sm" className="w-full sm:w-auto" onClick={onRefresh}><RefreshCcw className="h-4 w-4" />刷新</Button>
|
||||
</div>
|
||||
</div>
|
||||
<StatsSummary stats={stats} />
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.25fr)_minmax(320px,0.75fr)]">
|
||||
<SettingsCard title="文件夹分布" contentClassName="space-y-3">
|
||||
<FolderDistribution stats={stats} />
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{primaryCards.map((card) => (
|
||||
<div key={card.label} className="rounded-lg border bg-card p-4 shadow-[0_1px_2px_rgba(15,23,42,0.04)]">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className={cn("flex size-9 shrink-0 items-center justify-center rounded-lg", card.tone)}>{card.icon}</div>
|
||||
{card.detail && <Badge variant="secondary" className="rounded-md font-normal">{card.detail}</Badge>}
|
||||
</div>
|
||||
<div className="truncate text-[22px] font-semibold leading-7 text-foreground">{card.value}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{card.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{secondaryStats.map((item) => (
|
||||
<div key={item.label} className="rounded-lg border bg-background px-4 py-3">
|
||||
<div className="text-lg font-semibold leading-6 text-foreground">{item.value}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{item.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<SettingsCard title="收发趋势" contentClassName="pt-1">
|
||||
<StatsTrendChart points={stats?.trend || []} />
|
||||
</SettingsCard>
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(320px,0.72fr)]">
|
||||
<SettingsCard title="邮件分布" contentClassName="pt-1">
|
||||
<StatsDistribution items={stats?.distribution || []} />
|
||||
</SettingsCard>
|
||||
<SettingsCard title="存储用量">
|
||||
<div className="mb-3 flex items-end justify-between gap-3">
|
||||
<div className="text-lg font-semibold text-foreground">{quotaLabel}</div>
|
||||
<div className="text-sm font-semibold text-foreground">{stats?.quotaBytes ? `${quotaPct.toFixed(0)}%` : "不限"}</div>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${stats?.quotaBytes ? quotaPct : 12}%` }} />
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-muted-foreground">{quotaPct >= 90 ? "存储容量接近上限,请及时清理。" : "存储容量使用正常。"}</p>
|
||||
<StatsStorage quotaLabel={quotaLabel} quotaPct={quotaPct} hasQuota={!!stats?.quotaBytes} />
|
||||
</SettingsCard>
|
||||
</div>
|
||||
<SettingsCard title="常用联系人" contentClassName="pt-1">
|
||||
<StatsContacts contacts={stats?.topContacts || []} />
|
||||
</SettingsCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="rounded-lg border bg-background p-3">
|
||||
<div className="mb-3 flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5"><span className="size-2 rounded-full bg-blue-500" />收件</span>
|
||||
<span className="inline-flex items-center gap-1.5"><span className="size-2 rounded-full bg-emerald-500" />发件</span>
|
||||
</div>
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="h-[220px] w-full overflow-visible" role="img" aria-label="邮件收发趋势">
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((step) => {
|
||||
const y = padding.top + plotHeight * step
|
||||
return <line key={step} x1={padding.left} x2={width - padding.right} y1={y} y2={y} className="stroke-border" strokeDasharray={step === 1 ? undefined : "3 5"} />
|
||||
})}
|
||||
<path d={pathFor("incoming")} fill="none" className="stroke-blue-500" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d={pathFor("outgoing")} fill="none" className="stroke-emerald-500" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
{data.map((item, index) => (
|
||||
<g key={`${item.date}-${index}`}>
|
||||
<circle cx={xFor(index)} cy={yFor(item.incoming)} r="2.8" className="fill-blue-500" />
|
||||
<circle cx={xFor(index)} cy={yFor(item.outgoing)} r="2.8" className="fill-emerald-500" />
|
||||
</g>
|
||||
))}
|
||||
<text x="0" y={padding.top + 4} className="fill-muted-foreground text-[11px]">{maxValue}</text>
|
||||
<text x="0" y={padding.top + plotHeight + 4} className="fill-muted-foreground text-[11px]">0</text>
|
||||
{ticks.map((tick) => (
|
||||
<text key={`${tick.index}-${tick.label}`} x={xFor(tick.index)} y={height - 8} textAnchor="middle" className="fill-muted-foreground text-[11px]">{tick.label}</text>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function trendTicks(data: MailStats["trend"]) {
|
||||
if (data.length === 0) return []
|
||||
const count = Math.min(5, data.length)
|
||||
const seen = new Set<number>()
|
||||
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 (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{rows.map((row) => (
|
||||
<div key={row.key} className="rounded-lg border bg-background p-3">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<div className="flex min-w-0 items-center gap-2 font-medium text-foreground">
|
||||
<span className="text-muted-foreground">{distributionIcon(row.key)}</span>
|
||||
<span className="truncate">{row.label}</span>
|
||||
</div>
|
||||
<span className="shrink-0 font-semibold">{row.count}</span>
|
||||
</div>
|
||||
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-primary/80" style={{ width: `${Math.max(5, Math.round((row.count / maxCount) * 100))}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function distributionIcon(key: string) {
|
||||
const cls = "h-4 w-4"
|
||||
if (key === "archive") return <Archive className={cls} />
|
||||
if (key === "spam") return <MailX className={cls} />
|
||||
if (key === "trash") return <Trash2 className={cls} />
|
||||
if (key === "attachments") return <Image className={cls} />
|
||||
if (key === "starred") return <Star className={cls} />
|
||||
return <Mail className={cls} />
|
||||
}
|
||||
|
||||
function StatsStorage({ quotaLabel, quotaPct, hasQuota }: { quotaLabel: string; quotaPct: number; hasQuota: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex items-end justify-between gap-3">
|
||||
<div className="text-lg font-semibold text-foreground">{quotaLabel}</div>
|
||||
<div className="text-sm font-semibold text-foreground">{hasQuota ? `${quotaPct.toFixed(0)}%` : "不限"}</div>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${hasQuota ? quotaPct : 12}%` }} />
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-muted-foreground">{quotaPct >= 90 ? "存储容量接近上限,请及时清理。" : "存储容量使用正常。"}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatsContacts({ contacts }: { contacts: MailStats["topContacts"] }) {
|
||||
if (contacts.length === 0) return <EmptyState icon={<Users />} text="暂无常用联系人" description="有邮件往来后会显示联系人排行" />
|
||||
return (
|
||||
<div className="divide-y rounded-lg border bg-background">
|
||||
{contacts.map((item, index) => (
|
||||
<div key={item.email} className="grid grid-cols-[2rem_minmax(0,1fr)_auto] items-center gap-3 px-4 py-3 text-sm">
|
||||
<div className="flex size-7 items-center justify-center rounded-md bg-muted text-xs font-semibold text-muted-foreground">{index + 1}</div>
|
||||
<div className="min-w-0 truncate font-medium text-foreground">{item.email}</div>
|
||||
<Badge variant="secondary" className="rounded-md font-normal">{item.count} 封</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user