diff --git a/backend/service/points.go b/backend/service/points.go index 8ffc07a..c2bed31 100644 --- a/backend/service/points.go +++ b/backend/service/points.go @@ -125,6 +125,170 @@ type LedgerItem struct { CreatedAt time.Time `json:"created_at"` } +// LedgerParty 管理端流水相关用户摘要 +type LedgerParty struct { + ID uint `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` +} + +// AdminLedgerItem 管理端最近流水:单边入账带 user;用户互转合并为 from → to +type AdminLedgerItem struct { + ID uint `json:"id"` + Delta int `json:"delta"` + Balance int `json:"balance"` + Reason string `json:"reason"` + RefType string `json:"ref_type"` + RefID uint `json:"ref_id"` + Note string `json:"note"` + CreatedAt time.Time `json:"created_at"` + User *LedgerParty `json:"user,omitempty"` + From *LedgerParty `json:"from,omitempty"` + To *LedgerParty `json:"to,omitempty"` +} + +const ( + adminRecentLedgerLimit = 15 + adminRecentLedgerFetch = 32 + ledgerPairMaxDist = 8 +) + +func absInt(n int) int { + if n < 0 { + return -n + } + return n +} + +func ledgerPartyOf(u model.User) LedgerParty { + return LedgerParty{ID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar} +} + +func copyLedgerParty(p LedgerParty) *LedgerParty { + if p.ID == 0 { + return nil + } + cp := p + return &cp +} + +// complementaryLedgerRef 用户互转对向 ref_type;非互转返回空 +func complementaryLedgerRef(reason, refType string) string { + switch reason { + case model.PointReasonUnlockPost: + if refType == "post" { + return "post_earn" + } + if refType == "post_earn" { + return "post" + } + case model.PointReasonDownloadFile: + if refType == "attachment" { + return "attachment_earn" + } + if refType == "attachment_earn" { + return "attachment" + } + } + return "" +} + +func ledgersComplement(a, b model.PointLedger) bool { + comp := complementaryLedgerRef(a.Reason, a.RefType) + if comp == "" || b.Reason != a.Reason || b.RefID != a.RefID || b.RefType != comp { + return false + } + if absInt(a.Delta) != absInt(b.Delta) || absInt(a.Delta) == 0 { + return false + } + return (a.Delta > 0) != (b.Delta > 0) +} + +func idDist(a, b uint) int { + if a > b { + return int(a - b) + } + return int(b - a) +} + +func findComplementIndex(rows []model.PointLedger, i int, used []bool) int { + r := rows[i] + if complementaryLedgerRef(r.Reason, r.RefType) == "" { + return -1 + } + best := -1 + bestDist := ledgerPairMaxDist + 1 + for j := range rows { + if j == i || used[j] || !ledgersComplement(r, rows[j]) { + continue + } + d := idDist(r.ID, rows[j].ID) + if d == 0 || d > ledgerPairMaxDist || d >= bestDist { + continue + } + bestDist = d + best = j + } + return best +} + +// pairLedgerIndices 将用户互转的支出/收益配成一对;返回 [本条, 对向](无对向则为 -1) +func pairLedgerIndices(rows []model.PointLedger) [][2]int { + used := make([]bool, len(rows)) + out := make([][2]int, 0, len(rows)) + for i := range rows { + if used[i] { + continue + } + j := findComplementIndex(rows, i, used) + if j >= 0 { + used[i] = true + used[j] = true + out = append(out, [2]int{i, j}) + continue + } + used[i] = true + out = append(out, [2]int{i, -1}) + } + return out +} + +func mergePairedLedger(a, b model.PointLedger) model.PointLedger { + newer, older := a, b + if b.ID > a.ID { + newer, older = b, a + } + out := newer + out.Delta = absInt(newer.Delta) + if newer.Delta < 0 { + out.Delta = absInt(older.Delta) + } + debit, credit := a, b + if a.Delta > 0 { + debit, credit = b, a + } + if debit.Note != "" { + out.Note = debit.Note + } else { + out.Note = credit.Note + } + return out +} + +func transferEnds(a, b model.PointLedger) (fromID, toID uint) { + if a.Delta < 0 { + return a.UserID, b.UserID + } + if b.Delta < 0 { + return b.UserID, a.UserID + } + if a.Delta > 0 { + return b.UserID, a.UserID + } + return a.UserID, b.UserID +} + // Ledger 分页查询本人积分流水 func (s *PointsService) Ledger(userID uint, page, size int) ([]LedgerItem, int64, error) { if page < 1 { @@ -161,17 +325,17 @@ type DeltaDay struct { // PointsEconomyStats 全站积分与类型帖概览(管理端) type PointsEconomyStats struct { - TotalBalance int64 `json:"total_balance"` - LedgerCount int64 `json:"ledger_count"` - CheckinToday int64 `json:"checkin_today"` - OpenBounties int64 `json:"open_bounties"` - EscrowedPoints int64 `json:"escrowed_points"` - PostsByType map[string]int64 `json:"posts_by_type"` - RecentLedger []LedgerItem `json:"recent_ledger"` - CheckinSeries []DayCount `json:"checkin_series"` - LedgerByReason []NamedCount `json:"ledger_by_reason"` - DeltaSeries []DeltaDay `json:"delta_series"` - NetDelta7d int64 `json:"net_delta_7d"` + TotalBalance int64 `json:"total_balance"` + LedgerCount int64 `json:"ledger_count"` + CheckinToday int64 `json:"checkin_today"` + OpenBounties int64 `json:"open_bounties"` + EscrowedPoints int64 `json:"escrowed_points"` + PostsByType map[string]int64 `json:"posts_by_type"` + RecentLedger []AdminLedgerItem `json:"recent_ledger"` + CheckinSeries []DayCount `json:"checkin_series"` + LedgerByReason []NamedCount `json:"ledger_by_reason"` + DeltaSeries []DeltaDay `json:"delta_series"` + NetDelta7d int64 `json:"net_delta_7d"` } // AdminEconomyStats 管理端经济看板 @@ -181,6 +345,7 @@ func (s *PointsService) AdminEconomyStats() (*PointsEconomyStats, error) { CheckinSeries: make([]DayCount, 0), LedgerByReason: make([]NamedCount, 0), DeltaSeries: make([]DeltaDay, 0), + RecentLedger: make([]AdminLedgerItem, 0), } if err := s.db.Model(&model.User{}).Where("deleted_at IS NULL"). Select("COALESCE(SUM(points),0)").Scan(&st.TotalBalance).Error; err != nil { @@ -228,17 +393,11 @@ func (s *PointsService) AdminEconomyStats() (*PointsEconomyStats, error) { } } - var recent []model.PointLedger - if err := s.db.Order("id DESC").Limit(15).Find(&recent).Error; err != nil { + recent, err := s.buildAdminRecentLedger() + if err != nil { return nil, err } - st.RecentLedger = make([]LedgerItem, 0, len(recent)) - for _, r := range recent { - st.RecentLedger = append(st.RecentLedger, LedgerItem{ - ID: r.ID, Delta: r.Delta, Balance: r.Balance, Reason: r.Reason, - RefType: r.RefType, RefID: r.RefID, Note: r.Note, CreatedAt: r.CreatedAt, - }) - } + st.RecentLedger = recent now := time.Now() day0 := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) @@ -313,3 +472,206 @@ func (s *PointsService) AdminEconomyStats() (*PointsEconomyStats, error) { return st, nil } + +func (s *PointsService) lookupLedgerMate(r model.PointLedger) (*model.PointLedger, error) { + comp := complementaryLedgerRef(r.Reason, r.RefType) + if comp == "" || r.RefID == 0 { + return nil, nil + } + amt := absInt(r.Delta) + if amt == 0 { + return nil, nil + } + lo, hi := uint(1), r.ID+uint(ledgerPairMaxDist) + if r.ID > uint(ledgerPairMaxDist) { + lo = r.ID - uint(ledgerPairMaxDist) + } + var mates []model.PointLedger + q := s.db.Where("reason = ? AND ref_id = ? AND ref_type = ? AND id BETWEEN ? AND ? AND id <> ?", + r.Reason, r.RefID, comp, lo, hi, r.ID) + if r.Delta > 0 { + q = q.Where("delta = ?", -amt) + } else { + q = q.Where("delta = ?", amt) + } + if err := q.Find(&mates).Error; err != nil { + return nil, err + } + best := -1 + bestDist := ledgerPairMaxDist + 1 + for i := range mates { + d := idDist(r.ID, mates[i].ID) + if d == 0 || d > ledgerPairMaxDist || d >= bestDist { + continue + } + bestDist = d + best = i + } + if best < 0 { + return nil, nil + } + return &mates[best], nil +} + +func (s *PointsService) loadLedgerParties(ids []uint) (map[uint]LedgerParty, error) { + out := map[uint]LedgerParty{} + if len(ids) == 0 { + return out, nil + } + seen := map[uint]struct{}{} + uniq := make([]uint, 0, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + uniq = append(uniq, id) + } + if len(uniq) == 0 { + return out, nil + } + var users []model.User + if err := s.db.Unscoped().Select("id", "username", "nickname", "avatar"). + Where("id IN ?", uniq).Find(&users).Error; err != nil { + return nil, err + } + for _, u := range users { + out[u.ID] = ledgerPartyOf(u) + } + return out, nil +} + +func (s *PointsService) bountyPayers(rows []model.PointLedger) (map[uint]uint, error) { + ids := make([]uint, 0) + seen := map[uint]struct{}{} + for _, r := range rows { + if r.Reason != model.PointReasonBountyAward || r.RefType != "post" || r.RefID == 0 { + continue + } + if _, ok := seen[r.RefID]; ok { + continue + } + seen[r.RefID] = struct{}{} + ids = append(ids, r.RefID) + } + out := map[uint]uint{} + if len(ids) == 0 { + return out, nil + } + var posts []model.Post + if err := s.db.Unscoped().Select("id", "user_id").Where("id IN ?", ids).Find(&posts).Error; err != nil { + return nil, err + } + for _, p := range posts { + out[p.ID] = p.UserID + } + return out, nil +} + +func (s *PointsService) buildAdminRecentLedger() ([]AdminLedgerItem, error) { + out := make([]AdminLedgerItem, 0, adminRecentLedgerLimit) + var recent []model.PointLedger + if err := s.db.Order("id DESC").Limit(adminRecentLedgerFetch).Find(&recent).Error; err != nil { + return nil, err + } + if len(recent) == 0 { + return out, nil + } + + pairs := pairLedgerIndices(recent) + outside := map[int]model.PointLedger{} + for _, p := range pairs { + if p[1] >= 0 { + continue + } + r := recent[p[0]] + if complementaryLedgerRef(r.Reason, r.RefType) == "" { + continue + } + mate, err := s.lookupLedgerMate(r) + if err != nil { + return nil, err + } + if mate != nil { + outside[p[0]] = *mate + } + } + + payers, err := s.bountyPayers(recent) + if err != nil { + return nil, err + } + + userIDs := make([]uint, 0, len(recent)+len(outside)+len(payers)) + for _, r := range recent { + userIDs = append(userIDs, r.UserID) + } + for _, m := range outside { + userIDs = append(userIDs, m.UserID) + } + for _, uid := range payers { + userIDs = append(userIDs, uid) + } + parties, err := s.loadLedgerParties(userIDs) + if err != nil { + return nil, err + } + party := func(id uint) *LedgerParty { + if id == 0 { + return nil + } + if p, ok := parties[id]; ok { + return copyLedgerParty(p) + } + return &LedgerParty{ID: id, Nickname: "已注销"} + } + + itemFrom := func(r model.PointLedger) AdminLedgerItem { + return AdminLedgerItem{ + ID: r.ID, Delta: r.Delta, Balance: r.Balance, Reason: r.Reason, + RefType: r.RefType, RefID: r.RefID, Note: r.Note, CreatedAt: r.CreatedAt, + User: party(r.UserID), + } + } + + for _, p := range pairs { + if len(out) >= adminRecentLedgerLimit { + break + } + a := recent[p[0]] + var mate *model.PointLedger + if p[1] >= 0 { + m := recent[p[1]] + mate = &m + } else if m, ok := outside[p[0]]; ok { + mate = &m + } + + if mate != nil { + merged := mergePairedLedger(a, *mate) + fromID, toID := transferEnds(a, *mate) + item := itemFrom(merged) + item.Delta = absInt(merged.Delta) + item.User = nil + item.From = party(fromID) + item.To = party(toID) + out = append(out, item) + continue + } + + item := itemFrom(a) + if a.Reason == model.PointReasonBountyAward && a.Delta > 0 { + if payerID := payers[a.RefID]; payerID > 0 && payerID != a.UserID { + item.From = party(payerID) + item.To = party(a.UserID) + item.User = nil + item.Delta = a.Delta + } + } + out = append(out, item) + } + return out, nil +} diff --git a/backend/service/points_ledger_test.go b/backend/service/points_ledger_test.go new file mode 100644 index 0000000..5373320 --- /dev/null +++ b/backend/service/points_ledger_test.go @@ -0,0 +1,75 @@ +package service + +import ( + "testing" + + "github.com/freefire/jiang13-bbs/model" +) + +func TestComplementaryLedgerRef(t *testing.T) { + if got := complementaryLedgerRef(model.PointReasonUnlockPost, "post"); got != "post_earn" { + t.Fatalf("unlock debit: got %q", got) + } + if got := complementaryLedgerRef(model.PointReasonUnlockPost, "post_earn"); got != "post" { + t.Fatalf("unlock credit: got %q", got) + } + if got := complementaryLedgerRef(model.PointReasonDownloadFile, "attachment"); got != "attachment_earn" { + t.Fatalf("download debit: got %q", got) + } + if got := complementaryLedgerRef(model.PointReasonCheckin, "checkin"); got != "" { + t.Fatalf("checkin should not pair, got %q", got) + } +} + +func TestPairLedgerIndicesMergesUnlock(t *testing.T) { + rows := []model.PointLedger{ + {ID: 4, UserID: 2, Delta: 10, Reason: model.PointReasonUnlockPost, RefType: "post_earn", RefID: 9}, + {ID: 3, UserID: 1, Delta: -10, Reason: model.PointReasonUnlockPost, RefType: "post", RefID: 9}, + {ID: 2, UserID: 3, Delta: 5, Reason: model.PointReasonCheckin, RefType: "checkin", RefID: 1}, + } + pairs := pairLedgerIndices(rows) + if len(pairs) != 2 { + t.Fatalf("pairs=%d want 2: %#v", len(pairs), pairs) + } + if pairs[0] != [2]int{0, 1} { + t.Fatalf("first pair=%v want [0 1]", pairs[0]) + } + if pairs[1] != [2]int{2, -1} { + t.Fatalf("checkin pair=%v want [2 -1]", pairs[1]) + } +} + +func TestPairLedgerIndicesNearestSamePost(t *testing.T) { + rows := []model.PointLedger{ + {ID: 10, UserID: 2, Delta: 8, Reason: model.PointReasonUnlockPost, RefType: "post_earn", RefID: 1}, + {ID: 9, UserID: 1, Delta: -8, Reason: model.PointReasonUnlockPost, RefType: "post", RefID: 1}, + {ID: 8, UserID: 4, Delta: 8, Reason: model.PointReasonUnlockPost, RefType: "post_earn", RefID: 1}, + {ID: 7, UserID: 3, Delta: -8, Reason: model.PointReasonUnlockPost, RefType: "post", RefID: 1}, + } + pairs := pairLedgerIndices(rows) + if len(pairs) != 2 { + t.Fatalf("pairs=%d want 2: %#v", len(pairs), pairs) + } + if pairs[0] != [2]int{0, 1} || pairs[1] != [2]int{2, 3} { + t.Fatalf("pairs=%v", pairs) + } + from, to := transferEnds(rows[0], rows[1]) + if from != 1 || to != 2 { + t.Fatalf("flow=%d→%d want 1→2", from, to) + } +} + +func TestMergePairedLedgerKeepsNewer(t *testing.T) { + a := model.PointLedger{ID: 4, UserID: 2, Delta: 10, Note: "正文解锁收益", Reason: model.PointReasonUnlockPost} + b := model.PointLedger{ID: 3, UserID: 1, Delta: -10, Note: "解锁帖子:长标题", Reason: model.PointReasonUnlockPost} + got := mergePairedLedger(a, b) + if got.ID != 4 { + t.Fatalf("id=%d want 4", got.ID) + } + if got.Note != "解锁帖子:长标题" { + t.Fatalf("note=%q", got.Note) + } + if got.Delta != 10 { + t.Fatalf("delta=%d want 10", got.Delta) + } +} diff --git a/frontend/app/admin/economy/EconomyClient.tsx b/frontend/app/admin/economy/EconomyClient.tsx index 44d24e3..ceec513 100644 --- a/frontend/app/admin/economy/EconomyClient.tsx +++ b/frontend/app/admin/economy/EconomyClient.tsx @@ -1,12 +1,14 @@ "use client"; +import { useMemo, useState } from "react"; import Link from "next/link"; -import { Coins, Flame, Gift, TrendingUp } from "lucide-react"; -import type { EconomyStats } from "@/lib/api"; +import { ArrowRight, Coins, Flame, Gift, TrendingUp } from "lucide-react"; +import type { EconomyStats, LedgerParty, PointLedgerItem } from "@/lib/api"; import { postTypeLabel } from "@/lib/postMeta"; import { pointReasonLabel } from "@/lib/points"; import { formatRelative } from "@/lib/format"; -import { AdminPageHeader, AdminStatCard } from "@/components/admin"; +import { AdminPageHeader, AdminSegmented, AdminStatCard } from "@/components/admin"; +import Avatar from "@/components/Avatar"; import { AdminChartCard, DeltaBarChart, @@ -22,7 +24,223 @@ function netDeltaLabel(n: number) { return fmt(n); } +type LedgerKind = "checkin" | "bounty" | "file" | "typed" | "system"; +type LedgerFilter = + | "all" + | "granted" + | "spent" + | "poll" + | "lottery" + | "discussion" + | "question"; + +const LEDGER_FILTERS: { key: LedgerFilter; label: string }[] = [ + { key: "all", label: "全部" }, + { key: "granted", label: "发放" }, + { key: "spent", label: "消耗" }, + { key: "poll", label: "投票" }, + { key: "lottery", label: "抽奖" }, + { key: "discussion", label: "讨论" }, + { key: "question", label: "问答" }, +]; + +const EMPTY_LEDGER: PointLedgerItem[] = []; + +const NOTE_STRIP_EXTRAS = [ + "解锁帖子", + "下载附件", + "附件收益", + "正文解锁收益", + "悬赏采纳", + "悬赏截止后采纳扣款", + "悬赏过期退回", + "历史签到积分回填", + "每日签到", +]; + +function partyName(u: LedgerParty) { + return u.nickname || u.username || `#${u.id}`; +} + +function isToday(iso: string) { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return false; + const n = new Date(); + return ( + d.getFullYear() === n.getFullYear() && + d.getMonth() === n.getMonth() && + d.getDate() === n.getDate() + ); +} + +function ledgerKind(reason: string): LedgerKind { + switch (reason) { + case "checkin": + case "migrate_checkin": + return "checkin"; + case "bounty_escrow": + case "bounty_refund": + case "bounty_award": + return "bounty"; + case "download_file": + return "file"; + default: { + const blob = pointReasonLabel(reason); + if (/投票|抽奖|问答/.test(blob)) return "typed"; + return "system"; + } + } +} + +function ledgerKindEmoji(kind: LedgerKind) { + if (kind === "checkin") return "📅"; + if (kind === "bounty") return "🎯"; + if (kind === "file") return "📎"; + if (kind === "typed") return "🗳"; + return "⚙"; +} + +function blobOf(it: PointLedgerItem) { + return `${pointReasonLabel(it.reason)} ${it.note || ""} ${it.reason}`; +} + +function matchLedgerFilter(it: PointLedgerItem, filter: LedgerFilter) { + if (filter === "all") return true; + const delta = Number(it.delta) || 0; + const transfer = Boolean(it.from && it.to); + if (filter === "granted") return transfer || delta > 0; + if (filter === "spent") return transfer || delta < 0; + const blob = blobOf(it); + if (filter === "poll") return /投票/.test(blob); + if (filter === "lottery") return /抽奖/.test(blob); + if (filter === "question") return /问答/.test(blob); + if (filter === "discussion") return it.reason === "unlock_post" || /讨论/.test(blob); + return true; +} + +/** 去掉与类型标签重复的前缀,避免「悬赏托管:悬赏托管:悬赏」 */ +function ledgerDesc(reason: string, note: string) { + const label = pointReasonLabel(reason); + let s = (note || "").trim(); + const prefixes = [label, ...NOTE_STRIP_EXTRAS].filter(Boolean); + let guard = 0; + let changed = true; + while (changed && s && guard < 8) { + changed = false; + guard += 1; + for (const p of prefixes) { + if (s.startsWith(p)) { + s = s.slice(p.length).replace(/^[\s::·\-—]+/, ""); + changed = true; + } + } + } + if (s === label) return ""; + return s; +} + +function todayDelta(items: PointLedgerItem[]) { + let granted = 0; + let spent = 0; + for (const it of items) { + if (!isToday(it.created_at)) continue; + const n = Math.abs(Number(it.delta) || 0); + if (n === 0) continue; + if (it.from && it.to) { + granted += n; + spent += n; + continue; + } + if ((it.delta || 0) > 0) granted += n; + else spent += n; + } + return { granted, spent }; +} + +function LedgerUserLink({ user, compact }: { user: LedgerParty; compact?: boolean }) { + const name = partyName(user); + return ( + + + + {name} + + + ); +} + +function ledgerAria(it: PointLedgerItem) { + const reason = pointReasonLabel(it.reason); + const delta = Number(it.delta) || 0; + const signed = delta > 0 ? `+${fmt(delta)}` : fmt(delta); + if (it.from && it.to) { + return `${partyName(it.from)} 向 ${partyName(it.to)} 支付 ${fmt(Math.abs(delta))} 积分,${reason}`; + } + const who = it.user ? partyName(it.user) : "未知用户"; + return `${who} ${signed} 积分,${reason}`; +} + +function RecentLedgerRow({ it }: { it: PointLedgerItem }) { + const payer = it.from || it.user; + const payee = it.from && it.to ? it.to : undefined; + const kind = ledgerKind(it.reason); + const desc = ledgerDesc(it.reason, it.note || ""); + const delta = Number(it.delta); + const showAmount = Number.isFinite(delta) && delta !== 0; + + return ( +
  • + + {ledgerKindEmoji(kind)} + + + {payer ? ( + + ) : ( + + 未知用户 + + )} + + {payee ? ( + + + + + ) : null} + +
    + {pointReasonLabel(it.reason)} + {desc ? ( + + {desc} + + ) : ( + + )} + + {formatRelative(it.created_at)} + +
    + + 0 ? "var(--ledger-in)" : "var(--ledger-out)" } : undefined} + > + {showAmount ? (delta > 0 ? `+${fmt(delta)}` : fmt(delta)) : null} + +
  • + ); +} + export default function EconomyClient({ stats }: { stats: EconomyStats }) { + const [filter, setFilter] = useState("all"); + const ledger = stats.recent_ledger ?? EMPTY_LEDGER; + const checkinSeries = (stats.checkin_series || []).map((d) => ({ date: d.date, count: d.count, @@ -41,6 +259,11 @@ export default function EconomyClient({ stats }: { stats: EconomyStats }) { .sort((a, b) => b.count - a.count); const net = stats.net_delta_7d ?? 0; + const today = useMemo(() => todayDelta(ledger), [ledger]); + const filtered = useMemo( + () => ledger.filter((it) => matchLedgerFilter(it, filter)), + [ledger, filter] + ); return (
    @@ -122,37 +345,42 @@ export default function EconomyClient({ stats }: { stats: EconomyStats }) {
    -
    +
    - 最近流水 +

    + 最近流水 +

    +
    - {(stats.recent_ledger || []).length === 0 ? ( +

    + 今日 发放{" "} + + +{fmt(today.granted)} + + {" · "} + 消耗{" "} + + -{fmt(today.spent)} + +

    + {ledger.length === 0 ? (

    暂无流水

    + ) : filtered.length === 0 ? ( +

    该分类下暂无流水

    ) : (
      - {stats.recent_ledger.map((it, idx) => ( -
    • -
      -

      - {pointReasonLabel(it.reason)} - {it.note ? ` · ${it.note}` : ""} -

      -

      {formatRelative(it.created_at)}

      -
      - = 0 ? "var(--accent)" : "var(--danger)" }} - > - {it.delta >= 0 ? `+${it.delta}` : it.delta} - -
    • + {filtered.map((it) => ( + ))}
    )} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index a72e1e6..9d7e07c 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -32,6 +32,18 @@ --ok: #137333; --ok-soft: rgba(19, 115, 51, 0.1); + /* 积分看板最近流水:金额与类型标签 */ + --ledger-in: #16a34a; + --ledger-out: #dc2626; + --ledger-checkin-bg: #dcfce7; + --ledger-checkin-fg: #15803d; + --ledger-bounty-bg: #ffedd5; + --ledger-bounty-fg: #c2410c; + --ledger-file-bg: #dbeafe; + --ledger-file-fg: #1d4ed8; + --ledger-typed-bg: #f3e8ff; + --ledger-typed-fg: #7e22ce; + /* 工作台语义:导航选中态 / 榜单奖牌色 */ --nav-active-bg: var(--accent-soft); --nav-active-ink: var(--accent); @@ -94,6 +106,17 @@ --ok: #58d18a; --ok-soft: rgba(88, 209, 138, 0.15); + --ledger-in: #4ade80; + --ledger-out: #f87171; + --ledger-checkin-bg: rgba(22, 163, 74, 0.22); + --ledger-checkin-fg: #86efac; + --ledger-bounty-bg: rgba(194, 65, 12, 0.28); + --ledger-bounty-fg: #fdba74; + --ledger-file-bg: rgba(29, 78, 216, 0.28); + --ledger-file-fg: #93c5fd; + --ledger-typed-bg: rgba(126, 34, 206, 0.28); + --ledger-typed-fg: #e9d5ff; + /* 工作台语义 */ --nav-active-bg: rgba(141, 163, 255, 0.14); --nav-active-ink: #a9b8ff; @@ -3112,6 +3135,75 @@ a.j13-mention-token:hover { color: var(--accent); } + .ledger-feed { + max-width: 1000px; + } + .ledger-row { + display: flex; + align-items: center; + gap: 10px; + height: 64px; + padding: 12px 20px; + border-top: 1px solid var(--line); + } + .ledger-row:first-child { + border-top: none; + } + .ledger-kind-icon { + width: 30px; + height: 30px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 14px; + line-height: 1; + } + .ledger-kind-tag { + font-size: 11px; + padding: 2px 8px; + border-radius: 4px; + font-weight: 500; + line-height: 1.4; + white-space: nowrap; + flex-shrink: 0; + } + .ledger-kind-icon.is-checkin, + .ledger-kind-tag.is-checkin { + background: var(--ledger-checkin-bg); + color: var(--ledger-checkin-fg); + } + .ledger-kind-icon.is-bounty, + .ledger-kind-tag.is-bounty { + background: var(--ledger-bounty-bg); + color: var(--ledger-bounty-fg); + } + .ledger-kind-icon.is-file, + .ledger-kind-tag.is-file { + background: var(--ledger-file-bg); + color: var(--ledger-file-fg); + } + .ledger-kind-icon.is-typed, + .ledger-kind-tag.is-typed { + background: var(--ledger-typed-bg); + color: var(--ledger-typed-fg); + } + .ledger-kind-icon.is-system, + .ledger-kind-tag.is-system { + background: var(--tint); + color: var(--ink-2); + } + .ledger-amount { + width: 80px; + flex-shrink: 0; + text-align: right; + font-size: 15px; + font-weight: 600; + font-variant-numeric: tabular-nums; + line-height: 1.2; + } + .admin-toolbar { display: flex; flex-direction: column; diff --git a/frontend/app/u/[id]/ProfileHeroAvatar.tsx b/frontend/app/u/[id]/ProfileHeroAvatar.tsx index 37d930c..d96abe8 100644 --- a/frontend/app/u/[id]/ProfileHeroAvatar.tsx +++ b/frontend/app/u/[id]/ProfileHeroAvatar.tsx @@ -4,12 +4,15 @@ import { useState } from "react"; import Avatar from "@/components/Avatar"; import ImageLightbox from "@/components/markdown/ImageLightbox"; +/** Hero 约 120px 高时的头像边长 */ +const AVATAR_SIZE = 72; + const RING_STYLE = { - width: 80, - height: 80, + width: AVATAR_SIZE, + height: AVATAR_SIZE, lineHeight: 0, boxShadow: - "0 0 0 4px var(--panel), 0 0 0 6px color-mix(in srgb, var(--accent) 38%, transparent)", + "0 0 0 3px var(--panel), 0 0 0 4.5px color-mix(in srgb, var(--accent) 38%, transparent)", } as const; const RING_CLASS = @@ -38,11 +41,11 @@ export default function ProfileHeroAvatar({ data-tip="查看头像" onClick={() => setOpen(true)} > - + ) : ( - + )} {open && src ? ( diff --git a/frontend/app/u/[id]/UserProfileContent.tsx b/frontend/app/u/[id]/UserProfileContent.tsx index d158f25..3c6fed0 100644 --- a/frontend/app/u/[id]/UserProfileContent.tsx +++ b/frontend/app/u/[id]/UserProfileContent.tsx @@ -2,8 +2,15 @@ import Link from "next/link"; import { FileText, MessageCircle, AlertCircle } from "lucide-react"; import Pagination from "@/components/Pagination"; import { IconComment, IconEye, IconHeart } from "@/components/Icons"; +import { formatRelative } from "@/lib/format"; import type { UserProfile, UserCommentsResponse } from "@/lib/api"; +/** 本页卡片统一阴影;用 color-mix 让暗色主题自动加深,避免写死浅色阴影 */ +const CARD_SHADOW = "0 1px 3px color-mix(in srgb, #000 18%, transparent)"; + +const ITEM_BASE = + "group block rounded-xl px-6 py-5 border transition-[background-color,box-shadow,border-color] duration-150"; + /** * 用户主页内容区(纯服务端渲染): * tab 与分页全部走 URL(?tab=comments&page=N),刷新/后退/分享链接均保持一致。 @@ -31,44 +38,88 @@ export default function UserProfileContent({ {tab === "posts" ? (
    {Array.isArray(profile.posts) && profile.posts.length > 0 ? ( -
    - {profile.posts.map((post, i) => ( - -
    - {post.board.name} - {post.pinned > 0 && 置顶} - {post.recommended && 推荐} -
    -

    + {profile.posts.map((post) => { + const pinned = post.pinned > 0; + return ( + - {post.title} -

    -
    - {new Date(post.created_at).toLocaleDateString("zh-CN")} - {allowComments ? ( - - {post.comment_count} +
    + {pinned && ( + + 置顶 + + )} +

    + {post.title} +

    + {post.recommended && 推荐} +
    +
    + + {post.board.name} - ) : null} - - {post.view_count} - - - {post.like_count} - -
    - - ))} + {formatRelative(post.created_at)} + {allowComments ? ( + + + + {post.comment_count} + 评论 + + + ) : null} + + + + {post.view_count} + 浏览 + + + + + + {post.like_count} + 点赞 + + +
    + + ); + })}
    ) : ( -
    +

    暂无发帖

    @@ -81,7 +132,15 @@ export default function UserProfileContent({ />
    ) : commentError ? ( -
    +

    评论加载失败

    网络异常,请稍后重试

    @@ -89,13 +148,13 @@ export default function UserProfileContent({
    ) : commentData && (commentData.comments?.length ?? 0) > 0 ? (
    -
    - {(commentData.comments ?? []).map((c, i) => ( +
    + {(commentData.comments ?? []).map((c) => (
    评论于《{c.post_title}》 @@ -106,8 +165,11 @@ export default function UserProfileContent({ > {c.content}

    -
    - {new Date(c.created_at).toLocaleString("zh-CN")} +
    + {formatRelative(c.created_at)}
    ))} @@ -121,7 +183,14 @@ export default function UserProfileContent({ />
    ) : ( -
    +

    暂无评论

    diff --git a/frontend/app/u/[id]/page.tsx b/frontend/app/u/[id]/page.tsx index a4e9ffc..cfd1097 100644 --- a/frontend/app/u/[id]/page.tsx +++ b/frontend/app/u/[id]/page.tsx @@ -121,6 +121,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps) const u = data.user; const joinDate = new Date(u.created_at).toLocaleDateString("zh-CN"); const showPoints = data.stats.points > 0 || isOwner; + const hasHeroActions = isOwner || !!currentUser; // 导航项(桌面左栏 / 移动 chips 共用定义) const navItems: { key: UserTab; label: string; count?: number; icon: React.ReactNode; ownerOnly?: boolean }[] = [ @@ -134,12 +135,8 @@ export default async function UserPage({ params, searchParams }: UserPageProps) const visibleNav = navItems.filter((t) => !t.ownerOnly || isOwner); const tabHref = (t: UserTab) => (t === "posts" ? `/u/${id}` : `/u/${id}?tab=${t}`); - // 统计条数据 - const heroStats = [ - { label: "发帖", value: data.stats.post_count }, - ...(allowComments ? [{ label: "评论", value: data.stats.comment_count }] : []), - { label: "积分", value: data.stats.points, foot: data.stats.streak > 0 ? `连签 ${data.stats.streak} 天` : undefined }, - ]; + /** 本页卡片统一阴影;暗色随 color-mix 自动加深 */ + const cardShadow = "0 1px 3px color-mix(in srgb, #000 18%, transparent)"; return (
    @@ -149,8 +146,11 @@ export default async function UserPage({ params, searchParams }: UserPageProps) {u.nickname || u.username} - {/* ═══ Hero:石墨面板 + 淡靛几何装饰 + 身份信息 + 统计条 ═══ */} -
    + {/* ═══ Hero:头像 + 昵称 + 简介 + 行内统计,约 120px 高 ═══ */} +
    {/* 装饰层单独裁圆角,避免 overflow-hidden 裁掉管理下拉菜单 */}
    - {/* 本人操作按钮 / 访客私聊 */} + {/* 本人操作按钮 / 访客私聊:绝对定位在卡片右上角 */} {isOwner ? ( -
    +
    编辑资料 @@ -178,14 +178,14 @@ export default async function UserPage({ params, searchParams }: UserPageProps) href={`/u/${id}?tab=security`} aria-label="安全设置" data-tip="安全设置" - className="inline-flex w-8 h-8 items-center justify-center rounded-full border transition-colors hover:border-[var(--accent)] hover:text-[var(--accent)]" + className="inline-flex w-8 h-8 items-center justify-center rounded-full border transition-colors duration-150 hover:border-[var(--accent)] hover:text-[var(--accent)]" style={{ background: "var(--panel)", borderColor: "var(--line-2)", color: "var(--ink-2)" }} >
    ) : currentUser ? ( -
    +
    ) : null} - {/* 身份信息:纯文档流,头像 + 右侧文字 baseline 对齐 */} -
    -
    - -
    -
    -

    - {u.nickname || u.username} -

    - {u.role !== "user" && ( - - )} - {u.banned === true && ( - - 已封禁 - - )} -
    -

    - @{u.username} - · - - {joinDate} 加入 -

    -
    -
    - {u.signature && ( -

    - {u.signature} -

    - )} -
    - - {/* 统计条 */} + {/* 身份 + 行内统计:垂直居中,高度约 120px */}
    - {heroStats.map((s, i) => ( -
    0 ? { borderLeft: "1px solid var(--line)" } : undefined} - > -

    +

    +
    +

    - {s.value} -

    -

    - {s.label} - {s.foot && ( - - {s.foot} - - )} -

    + {u.nickname || u.username} +

    + {u.role !== "user" && ( + + )} + {u.banned === true && ( + + 已封禁 + + )}
    - ))} +

    + + {data.stats.post_count} + {" "}帖子 + + {allowComments && ( + <> + · + + {data.stats.comment_count} + {" "}评论 + + + )} + · + + {data.stats.points} + {" "}积分 + + {data.stats.streak > 0 && ( + <> + · + + + {data.stats.streak}天连签 + + + )} +

    + {u.signature && ( +

    + {u.signature} +

    + )} +
    - {/* 移动端 Tab chips(lg 以下) */} -
    + {/* 移动端 Tab chips(md 以下,约 768px) */} +
    {visibleNav.map((t) => (
    - {/* ═══ 三栏:左导航 / 中内容 / 右信息 ═══ */} -
    + {/* ═══ 三栏:左 240 / 中自适应 / 右 300;md 以下侧栏折叠 ═══ */} +
    {/* 左栏:Tab 导航 */} -
    -
    {children}
    + {/* 捕获阶段拦截 mousedown:冒泡阶段太晚,浏览器已开始 SVG 多柱选区黑框 */} +
    e.preventDefault()} + > + {children} +
    ); } @@ -68,7 +83,7 @@ export function VisitTrendChart({ const rows = data.map((d) => ({ ...d, label: shortDate(d.date) })); return ( - + @@ -82,8 +97,8 @@ export function VisitTrendChart({ - - + + @@ -111,12 +126,12 @@ export function DualLineChart({ const rows = data.map((d) => ({ ...d, label: shortDate(String(d.date)) })); return ( - + - - + + @@ -138,11 +153,11 @@ export function SingleAreaChart({ const rows = data.map((d) => ({ ...d, label: shortDate(String(d.date)) })); return ( - + - + @@ -170,24 +185,24 @@ export function NamedBarChart({ if (layout === "horizontal") { return ( - + - - + + ); } return ( - + - - + + ); @@ -197,20 +212,19 @@ export function NamedPieChart({ data }: { data: { label: string; count: number } const total = data.reduce((s, d) => s + d.count, 0) || 1; return ( - + {data.map((_, i) => ( ))} - { const n = typeof value === "number" ? value : Number(value) || 0; return [`${n}(${Math.round((n / total) * 100)}%)`, String(name ?? "")]; }} /> - + ); @@ -224,14 +238,14 @@ export function DeltaBarChart({ const rows = data.map((d) => ({ ...d, label: shortDate(d.date) })); return ( - + - - - - + + + + ); diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index b1ab7ed..c0b581a 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -971,6 +971,13 @@ export async function apiGetPoints(): Promise<{ points: number }> { return res.json(); } +export interface LedgerParty { + id: number; + username: string; + nickname: string; + avatar: string; +} + export interface PointLedgerItem { id: number; delta: number; @@ -980,6 +987,9 @@ export interface PointLedgerItem { ref_id: number; note: string; created_at: string; + user?: LedgerParty; + from?: LedgerParty; + to?: LedgerParty; } export async function apiGetPointsLedger(