账号上下架增加统计
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
package listingstatus
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/timeutil"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 统计用的事件类型(与 to_status 对齐时优先用这些标准值)。
|
||||
const (
|
||||
EventPublished = "published"
|
||||
EventOffline = "offline"
|
||||
EventRented = "rented"
|
||||
EventCompleted = "completed"
|
||||
EventSealed = "sealed"
|
||||
EventAbnormal = "abnormal"
|
||||
)
|
||||
|
||||
// 来源
|
||||
const (
|
||||
SourceSeller = "seller"
|
||||
SourceAdmin = "admin"
|
||||
SourceReview = "review"
|
||||
SourceOrder = "order"
|
||||
SourcePickup = "pickup"
|
||||
SourceDispute = "dispute"
|
||||
SourceSystem = "system"
|
||||
)
|
||||
|
||||
// 操作者类型
|
||||
const (
|
||||
ActorUser = "user"
|
||||
ActorAdmin = "admin"
|
||||
ActorSystem = "system"
|
||||
)
|
||||
|
||||
// Entry 单次状态变更事件。
|
||||
type Entry struct {
|
||||
ListingID uint64
|
||||
OwnerID uint64
|
||||
EventType string
|
||||
FromStatus string
|
||||
ToStatus string
|
||||
Source string
|
||||
ActorType string
|
||||
ActorID uint64
|
||||
Remark string
|
||||
// CreatedAt 为空时使用上海时间当前时刻。
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Append 写入一条状态事件。from==to 或 event 无法识别时跳过。
|
||||
func Append(tx *gorm.DB, entry Entry) error {
|
||||
if tx == nil {
|
||||
return nil
|
||||
}
|
||||
from := strings.TrimSpace(entry.FromStatus)
|
||||
to := strings.TrimSpace(entry.ToStatus)
|
||||
if to == "" {
|
||||
to = strings.TrimSpace(entry.EventType)
|
||||
}
|
||||
if to == "" || from == to {
|
||||
return nil
|
||||
}
|
||||
eventType := strings.TrimSpace(entry.EventType)
|
||||
if eventType == "" {
|
||||
eventType = normalizeEventType(to)
|
||||
}
|
||||
if eventType == "" {
|
||||
return nil
|
||||
}
|
||||
createdAt := entry.CreatedAt
|
||||
if createdAt.IsZero() {
|
||||
createdAt = timeutil.ShanghaiNow()
|
||||
}
|
||||
row := model.ListingStatusEvent{
|
||||
ListingID: entry.ListingID,
|
||||
OwnerID: entry.OwnerID,
|
||||
EventType: eventType,
|
||||
FromStatus: from,
|
||||
ToStatus: to,
|
||||
Source: strings.TrimSpace(entry.Source),
|
||||
ActorType: strings.TrimSpace(entry.ActorType),
|
||||
ActorID: entry.ActorID,
|
||||
Remark: strings.TrimSpace(entry.Remark),
|
||||
CreatedAt: createdAt,
|
||||
}
|
||||
return tx.Create(&row).Error
|
||||
}
|
||||
|
||||
// AppendTransition 根据 listing 变更前后状态写事件。
|
||||
func AppendTransition(
|
||||
tx *gorm.DB,
|
||||
listing *model.RentalListing,
|
||||
fromStatus string,
|
||||
source string,
|
||||
actorType string,
|
||||
actorID uint64,
|
||||
remark string,
|
||||
) error {
|
||||
if listing == nil {
|
||||
return nil
|
||||
}
|
||||
return Append(tx, Entry{
|
||||
ListingID: listing.ID,
|
||||
OwnerID: listing.OwnerID,
|
||||
EventType: normalizeEventType(listing.Status),
|
||||
FromStatus: fromStatus,
|
||||
ToStatus: listing.Status,
|
||||
Source: source,
|
||||
ActorType: actorType,
|
||||
ActorID: actorID,
|
||||
Remark: remark,
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeEventType(status string) string {
|
||||
switch strings.TrimSpace(status) {
|
||||
case EventPublished, EventOffline, EventRented, EventCompleted, EventSealed, EventAbnormal:
|
||||
return status
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package listingstatus
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func setupTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.ListingStatusEvent{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestAppendSkipsUnchangedOrUnknown(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
|
||||
if err := Append(db, Entry{ListingID: 1, FromStatus: "published", ToStatus: "published", EventType: EventPublished}); err != nil {
|
||||
t.Fatalf("same status should skip: %v", err)
|
||||
}
|
||||
if err := Append(db, Entry{ListingID: 1, FromStatus: "draft", ToStatus: "draft", EventType: ""}); err != nil {
|
||||
t.Fatalf("unknown should skip: %v", err)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&model.ListingStatusEvent{}).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("expected 0 events, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendTransitionWritesPublishedAndOffline(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
listing := &model.RentalListing{ID: 9, OwnerID: 3, Status: EventPublished}
|
||||
if err := AppendTransition(db, listing, "draft", SourceSeller, ActorUser, 3, "发布"); err != nil {
|
||||
t.Fatalf("append published: %v", err)
|
||||
}
|
||||
listing.Status = EventOffline
|
||||
if err := AppendTransition(db, listing, EventPublished, SourceAdmin, ActorAdmin, 1, "后台下架"); err != nil {
|
||||
t.Fatalf("append offline: %v", err)
|
||||
}
|
||||
|
||||
var rows []model.ListingStatusEvent
|
||||
if err := db.Order("id asc").Find(&rows).Error; err != nil {
|
||||
t.Fatalf("find: %v", err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("expected 2 events, got %d", len(rows))
|
||||
}
|
||||
if rows[0].EventType != EventPublished || rows[0].Source != SourceSeller {
|
||||
t.Fatalf("unexpected first event: %#v", rows[0])
|
||||
}
|
||||
if rows[1].EventType != EventOffline || rows[1].Source != SourceAdmin {
|
||||
t.Fatalf("unexpected second event: %#v", rows[1])
|
||||
}
|
||||
if rows[0].CreatedAt.IsZero() || rows[1].CreatedAt.After(time.Now().Add(time.Minute)) {
|
||||
t.Fatalf("unexpected timestamps: %v %v", rows[0].CreatedAt, rows[1].CreatedAt)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// ListingStatusEvent 记录商品上架/主动下架/交易离架等状态变更,供后台日统计使用。
|
||||
type ListingStatusEvent struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
ListingID uint64 `gorm:"not null;index" json:"listing_id"`
|
||||
OwnerID uint64 `gorm:"not null;default:0" json:"owner_id"`
|
||||
EventType string `gorm:"size:32;not null" json:"event_type"`
|
||||
FromStatus string `gorm:"size:32;not null;default:''" json:"from_status"`
|
||||
ToStatus string `gorm:"size:32;not null;default:''" json:"to_status"`
|
||||
Source string `gorm:"size:32;not null;default:''" json:"source"`
|
||||
ActorType string `gorm:"size:16;not null;default:''" json:"actor_type"`
|
||||
ActorID uint64 `gorm:"not null;default:0" json:"actor_id"`
|
||||
Remark string `gorm:"size:255;not null;default:''" json:"remark"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (ListingStatusEvent) TableName() string {
|
||||
return "listing_status_events"
|
||||
}
|
||||
@@ -3,11 +3,12 @@ package admindashboard
|
||||
import "time"
|
||||
|
||||
type DashboardDTO struct {
|
||||
Metrics MetricsDTO `json:"metrics"`
|
||||
Pending PendingDTO `json:"pending"`
|
||||
RecentOrders []RecentOrderDTO `json:"recent_orders"`
|
||||
RecentDisputes []RecentDisputeDTO `json:"recent_disputes"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
Metrics MetricsDTO `json:"metrics"`
|
||||
Pending PendingDTO `json:"pending"`
|
||||
ListingDaily ListingDailyOverviewDTO `json:"listing_daily"`
|
||||
RecentOrders []RecentOrderDTO `json:"recent_orders"`
|
||||
RecentDisputes []RecentDisputeDTO `json:"recent_disputes"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
}
|
||||
|
||||
type MetricsDTO struct {
|
||||
@@ -21,6 +22,21 @@ type MetricsDTO struct {
|
||||
TodayLedgerAmountCent int64 `json:"today_ledger_amount_cent"`
|
||||
}
|
||||
|
||||
// ListingDailyOverviewDTO 商品上下架统计:今日三组 + 近 7 日趋势。
|
||||
type ListingDailyOverviewDTO struct {
|
||||
Today ListingDayStatsDTO `json:"today"`
|
||||
Trend []ListingDayStatsDTO `json:"trend"`
|
||||
Days int `json:"days"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
|
||||
type ListingDayStatsDTO struct {
|
||||
Date string `json:"date"` // YYYY-MM-DD(上海时区)
|
||||
PublishedCount int64 `json:"published_count"`
|
||||
ActiveOfflineCount int64 `json:"active_offline_count"` // 号主 + 后台主动下架
|
||||
TradeLeaveCount int64 `json:"trade_leave_count"` // rented / completed / sealed / 交易侧归档下架
|
||||
}
|
||||
|
||||
type PendingDTO struct {
|
||||
ListingReviews int64 `json:"listing_reviews"`
|
||||
Disputes int64 `json:"disputes"`
|
||||
|
||||
@@ -4,11 +4,15 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/listingstatus"
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/timeutil"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const listingDailyTrendDays = 7
|
||||
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
@@ -19,8 +23,9 @@ func NewRepository(db *gorm.DB) *Repository {
|
||||
|
||||
func (r *Repository) Summary(ctx context.Context) (*DashboardDTO, error) {
|
||||
db := r.db.WithContext(ctx)
|
||||
now := time.Now()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
loc := timeutil.ShanghaiLocation()
|
||||
now := timeutil.ShanghaiNow()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||
metrics := MetricsDTO{}
|
||||
pending := PendingDTO{}
|
||||
|
||||
@@ -63,6 +68,11 @@ func (r *Repository) Summary(ctx context.Context) (*DashboardDTO, error) {
|
||||
if err := db.Model(&model.RentalOrder{}).Where("status IN ?", []string{"pending_checkout_confirm", "pending_checkout_accept"}).Count(&pending.PendingReturnConfirms).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
listingDaily, err := r.listingDailyOverview(ctx, now, listingDailyTrendDays)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recentOrders, err := r.recentOrders(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -74,12 +84,103 @@ func (r *Repository) Summary(ctx context.Context) (*DashboardDTO, error) {
|
||||
return &DashboardDTO{
|
||||
Metrics: metrics,
|
||||
Pending: pending,
|
||||
ListingDaily: listingDaily,
|
||||
RecentOrders: recentOrders,
|
||||
RecentDisputes: recentDisputes,
|
||||
GeneratedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, days int) (ListingDailyOverviewDTO, error) {
|
||||
if days <= 0 {
|
||||
days = listingDailyTrendDays
|
||||
}
|
||||
loc := timeutil.ShanghaiLocation()
|
||||
now = now.In(loc)
|
||||
// 右开区间 [start, end)
|
||||
end := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, 1)
|
||||
start := end.AddDate(0, 0, -days)
|
||||
|
||||
type eventRow struct {
|
||||
EventType string
|
||||
Source string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
events := make([]eventRow, 0)
|
||||
if err := r.db.WithContext(ctx).Model(&model.ListingStatusEvent{}).
|
||||
Select("event_type, source, created_at").
|
||||
Where("created_at >= ? AND created_at < ?", start, end).
|
||||
Scan(&events).Error; err != nil {
|
||||
return ListingDailyOverviewDTO{}, err
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
published int64
|
||||
activeOffline int64
|
||||
tradeLeave int64
|
||||
}
|
||||
byDay := make(map[string]*bucket, days)
|
||||
for _, ev := range events {
|
||||
key := ev.CreatedAt.In(loc).Format("2006-01-02")
|
||||
b := byDay[key]
|
||||
if b == nil {
|
||||
b = &bucket{}
|
||||
byDay[key] = b
|
||||
}
|
||||
switch {
|
||||
case ev.EventType == listingstatus.EventPublished:
|
||||
b.published++
|
||||
case isActiveOfflineEvent(ev.EventType, ev.Source):
|
||||
b.activeOffline++
|
||||
case isTradeLeaveEvent(ev.EventType, ev.Source):
|
||||
b.tradeLeave++
|
||||
}
|
||||
}
|
||||
|
||||
trend := make([]ListingDayStatsDTO, 0, days)
|
||||
for i := days - 1; i >= 0; i-- {
|
||||
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, -i)
|
||||
key := day.Format("2006-01-02")
|
||||
item := ListingDayStatsDTO{Date: key}
|
||||
if b := byDay[key]; b != nil {
|
||||
item.PublishedCount = b.published
|
||||
item.ActiveOfflineCount = b.activeOffline
|
||||
item.TradeLeaveCount = b.tradeLeave
|
||||
}
|
||||
trend = append(trend, item)
|
||||
}
|
||||
|
||||
todayStats := ListingDayStatsDTO{Date: now.Format("2006-01-02")}
|
||||
if len(trend) > 0 {
|
||||
todayStats = trend[len(trend)-1]
|
||||
}
|
||||
return ListingDailyOverviewDTO{
|
||||
Today: todayStats,
|
||||
Trend: trend,
|
||||
Days: days,
|
||||
Timezone: "Asia/Shanghai",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isActiveOfflineEvent(eventType, source string) bool {
|
||||
return eventType == listingstatus.EventOffline &&
|
||||
(source == listingstatus.SourceSeller || source == listingstatus.SourceAdmin)
|
||||
}
|
||||
|
||||
func isTradeLeaveEvent(eventType, source string) bool {
|
||||
switch eventType {
|
||||
case listingstatus.EventRented, listingstatus.EventCompleted, listingstatus.EventSealed:
|
||||
return true
|
||||
case listingstatus.EventOffline:
|
||||
return source == listingstatus.SourceOrder ||
|
||||
source == listingstatus.SourceDispute ||
|
||||
source == listingstatus.SourcePickup ||
|
||||
source == listingstatus.SourceSystem
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Repository) recentOrders(ctx context.Context) ([]RecentOrderDTO, error) {
|
||||
rows := make([]RecentOrderDTO, 0)
|
||||
err := r.db.WithContext(ctx).Table("rental_orders AS o").
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/listingstatus"
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/notification"
|
||||
"hfb_sys/backend/internal/modules/wallet"
|
||||
@@ -76,6 +77,17 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
|
||||
listing.PublishedAt = nil
|
||||
account.Status = "offline"
|
||||
}
|
||||
if err := listingstatus.AppendTransition(
|
||||
tx,
|
||||
&listing,
|
||||
beforeListingStatus,
|
||||
listingstatus.SourceDispute,
|
||||
listingstatus.ActorAdmin,
|
||||
adminID,
|
||||
"仲裁结案",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := wallet.AppendEntries(tx, settlement.Entries...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/listingstatus"
|
||||
"hfb_sys/backend/internal/model"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
@@ -64,6 +65,9 @@ func (r *Repository) Create(ctx context.Context, ownerID uint64, req CreateReque
|
||||
if err := tx.Create(&listing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := listingstatus.AppendTransition(tx, &listing, "", listingstatus.SourceSeller, listingstatus.ActorUser, ownerID, "发布上架"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 发布提交时建发布群
|
||||
var conversationID uint64
|
||||
@@ -235,6 +239,7 @@ func (r *Repository) Update(ctx context.Context, ownerID uint64, listingID uint6
|
||||
|
||||
listing.PriceCent = normalizedListingPriceCent(req)
|
||||
listing.DepositAmountCent = req.DepositAmountCent
|
||||
fromStatus := listing.Status
|
||||
listingStatus, reviewStatus, publishedAt := initialPublishState(reviewRequired)
|
||||
listing.Status = listingStatus
|
||||
listing.ReviewStatus = reviewStatus
|
||||
@@ -247,6 +252,9 @@ func (r *Repository) Update(ctx context.Context, ownerID uint64, listingID uint6
|
||||
if err := tx.Save(listing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := listingstatus.AppendTransition(tx, listing, fromStatus, listingstatus.SourceSeller, listingstatus.ActorUser, ownerID, "编辑后上架"); err != nil {
|
||||
return err
|
||||
}
|
||||
dto = toDTO(*account, *listing)
|
||||
return nil
|
||||
})
|
||||
@@ -263,6 +271,7 @@ func (r *Repository) SubmitReview(ctx context.Context, ownerID uint64, listingID
|
||||
if listingLockedForOwnerMutation(listing) {
|
||||
return ErrListingLocked
|
||||
}
|
||||
fromStatus := listing.Status
|
||||
listingStatus, reviewStatus, publishedAt := initialPublishState(reviewRequired)
|
||||
listing.Status = listingStatus
|
||||
listing.ReviewStatus = reviewStatus
|
||||
@@ -275,6 +284,9 @@ func (r *Repository) SubmitReview(ctx context.Context, ownerID uint64, listingID
|
||||
if err := tx.Save(listing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := listingstatus.AppendTransition(tx, listing, fromStatus, listingstatus.SourceSeller, listingstatus.ActorUser, ownerID, "提交发布"); err != nil {
|
||||
return err
|
||||
}
|
||||
dto = toDTO(*account, *listing)
|
||||
return nil
|
||||
})
|
||||
@@ -353,6 +365,7 @@ func (r *Repository) Offline(ctx context.Context, ownerID uint64, listingID uint
|
||||
if listingLockedForOwnerMutation(listing) {
|
||||
return ErrListingLocked
|
||||
}
|
||||
fromStatus := listing.Status
|
||||
listing.Status = "offline"
|
||||
listing.ReviewStatus = "none"
|
||||
listing.ReviewReason = "号主已手动下架"
|
||||
@@ -364,6 +377,9 @@ func (r *Repository) Offline(ctx context.Context, ownerID uint64, listingID uint
|
||||
if err := tx.Save(listing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := listingstatus.AppendTransition(tx, listing, fromStatus, listingstatus.SourceSeller, listingstatus.ActorUser, ownerID, "号主手动下架"); err != nil {
|
||||
return err
|
||||
}
|
||||
dto = toDTO(*account, *listing)
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/listingstatus"
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/notification"
|
||||
|
||||
@@ -174,6 +175,13 @@ func (r *Repository) adminUpdateStatus(ctx context.Context, adminID uint64, list
|
||||
if err := tx.Save(listing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
source := listingstatus.SourceAdmin
|
||||
if listingStatus == "published" {
|
||||
source = listingstatus.SourceReview
|
||||
}
|
||||
if err := listingstatus.AppendTransition(tx, listing, beforeListingStatus, source, listingstatus.ActorAdmin, adminID, req.Reason); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := notification.Append(tx, notification.Entry{
|
||||
UserID: listing.OwnerID,
|
||||
Type: "listing_admin",
|
||||
@@ -214,6 +222,7 @@ func (r *Repository) Approve(ctx context.Context, listingID uint64) (*ListingDTO
|
||||
if listing.Status == "rented" || listing.InTransaction {
|
||||
return ErrListingLocked
|
||||
}
|
||||
fromStatus := listing.Status
|
||||
now := time.Now()
|
||||
listing.Status = "published"
|
||||
listing.ReviewStatus = "approved"
|
||||
@@ -226,6 +235,9 @@ func (r *Repository) Approve(ctx context.Context, listingID uint64) (*ListingDTO
|
||||
if err := tx.Save(listing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := listingstatus.AppendTransition(tx, listing, fromStatus, listingstatus.SourceReview, listingstatus.ActorAdmin, 0, "审核通过上架"); err != nil {
|
||||
return err
|
||||
}
|
||||
listingID := listing.ID
|
||||
if err := notification.Append(tx, notification.Entry{
|
||||
UserID: listing.OwnerID,
|
||||
|
||||
@@ -35,7 +35,9 @@ func (r *Repository) AdminClose(ctx context.Context, adminID uint64, orderID uin
|
||||
order.HandoffStatus = handoffStatusAdminClosed
|
||||
order.SettlementStatus = settlementStatusClosed
|
||||
order.SettledAt = &now
|
||||
archiveAssets(listing, account)
|
||||
if err := archiveAssets(tx, listing, account); err != nil {
|
||||
return err
|
||||
}
|
||||
if beforeOrderStatus != orderStatusPendingPayment {
|
||||
totalCent := order.RentAmountCent + order.DepositAmountCent
|
||||
// 押金暂扣:若订单已暂扣,拦截退款中的押金部分挂起不退,其余照常原路退。
|
||||
@@ -118,7 +120,9 @@ func (r *Repository) AdminMarkAbnormal(ctx context.Context, adminID uint64, orde
|
||||
beforeAccountStatus := account.Status
|
||||
order.Status = orderStatusAbnormal
|
||||
order.HandoffStatus = handoffStatusAdminAbnormal
|
||||
markAssetsAbnormal(listing, account)
|
||||
if err := markAssetsAbnormal(tx, listing, account); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := notification.Append(tx,
|
||||
notification.Entry{
|
||||
UserID: order.RenterID,
|
||||
@@ -450,7 +454,9 @@ func (r *Repository) AdminRejectRefund(ctx context.Context, orderID uint64, acti
|
||||
order.SettlementStatus = settlementStatusUnsettled
|
||||
order.SettledAt = nil
|
||||
reserveListingForOrder(listing)
|
||||
markAssetsRented(listing, account)
|
||||
if err := markAssetsRented(tx, listing, account); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
case "close":
|
||||
now := time.Now()
|
||||
@@ -458,7 +464,9 @@ func (r *Repository) AdminRejectRefund(ctx context.Context, orderID uint64, acti
|
||||
order.HandoffStatus = handoffStatusAdminClosed
|
||||
order.SettlementStatus = settlementStatusClosed
|
||||
order.SettledAt = &now
|
||||
archiveAssets(listing, account)
|
||||
if err := archiveAssets(tx, listing, account); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
default:
|
||||
return errors.New("无效的驳回操作,可选 restore 或 close")
|
||||
@@ -500,7 +508,9 @@ func (r *Repository) AdminSeal(ctx context.Context, adminID uint64, orderID uint
|
||||
order.HandoffStatus = handoffStatusAdminClosed
|
||||
order.SettlementStatus = settlementStatusClosed
|
||||
order.SettledAt = &now
|
||||
sealAssets(listing, account)
|
||||
if err := sealAssets(tx, listing, account); err != nil {
|
||||
return err
|
||||
}
|
||||
if beforeOrderStatus != orderStatusPendingPayment {
|
||||
totalCent := order.RentAmountCent + order.DepositAmountCent
|
||||
// 押金暂扣:若订单已暂扣,拦截退款中的押金部分挂起不退,其余照常原路退。
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"hfb_sys/backend/internal/listingstatus"
|
||||
"hfb_sys/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -45,41 +46,53 @@ func reserveListingForOrder(listing *model.RentalListing) {
|
||||
listing.InTransaction = true
|
||||
}
|
||||
|
||||
func markAssetsRented(listing *model.RentalListing, account *model.GameAccount) {
|
||||
func markAssetsRented(tx *gorm.DB, listing *model.RentalListing, account *model.GameAccount) error {
|
||||
from := listing.Status
|
||||
listing.Status = listingStatusRented
|
||||
account.Status = accountStatusRented
|
||||
return listingstatus.AppendTransition(tx, listing, from, listingstatus.SourceOrder, listingstatus.ActorSystem, 0, "支付成功租出")
|
||||
}
|
||||
|
||||
func releaseAssetsForRental(listing *model.RentalListing, account *model.GameAccount) {
|
||||
func releaseAssetsForRental(tx *gorm.DB, listing *model.RentalListing, account *model.GameAccount) error {
|
||||
from := listing.Status
|
||||
listing.Status = listingStatusPublished
|
||||
listing.InTransaction = false
|
||||
account.Status = accountStatusPublished
|
||||
return listingstatus.AppendTransition(tx, listing, from, listingstatus.SourceOrder, listingstatus.ActorSystem, 0, "订单取消恢复上架")
|
||||
}
|
||||
|
||||
func archiveAssets(listing *model.RentalListing, account *model.GameAccount) {
|
||||
func archiveAssets(tx *gorm.DB, listing *model.RentalListing, account *model.GameAccount) error {
|
||||
from := listing.Status
|
||||
listing.Status = listingStatusOffline
|
||||
listing.InTransaction = false
|
||||
listing.PublishedAt = nil
|
||||
account.Status = accountStatusOffline
|
||||
return listingstatus.AppendTransition(tx, listing, from, listingstatus.SourceOrder, listingstatus.ActorSystem, 0, "订单归档下架")
|
||||
}
|
||||
|
||||
func sealAssets(listing *model.RentalListing, account *model.GameAccount) {
|
||||
func sealAssets(tx *gorm.DB, listing *model.RentalListing, account *model.GameAccount) error {
|
||||
from := listing.Status
|
||||
listing.Status = listingStatusSealed
|
||||
listing.InTransaction = false
|
||||
listing.PublishedAt = nil
|
||||
account.Status = accountStatusSealed
|
||||
return listingstatus.AppendTransition(tx, listing, from, listingstatus.SourceOrder, listingstatus.ActorSystem, 0, "订单封存")
|
||||
}
|
||||
|
||||
func completeAssets(listing *model.RentalListing, account *model.GameAccount) {
|
||||
func completeAssets(tx *gorm.DB, listing *model.RentalListing, account *model.GameAccount) error {
|
||||
from := listing.Status
|
||||
listing.Status = listingStatusCompleted
|
||||
listing.InTransaction = false
|
||||
listing.PublishedAt = nil
|
||||
account.Status = accountStatusOffline
|
||||
return listingstatus.AppendTransition(tx, listing, from, listingstatus.SourceOrder, listingstatus.ActorSystem, 0, "订单完成售出")
|
||||
}
|
||||
|
||||
func markAssetsAbnormal(listing *model.RentalListing, account *model.GameAccount) {
|
||||
func markAssetsAbnormal(tx *gorm.DB, listing *model.RentalListing, account *model.GameAccount) error {
|
||||
from := listing.Status
|
||||
listing.Status = listingStatusAbnormal
|
||||
listing.InTransaction = false
|
||||
listing.PublishedAt = nil
|
||||
account.Status = accountStatusAbnormal
|
||||
return listingstatus.AppendTransition(tx, listing, from, listingstatus.SourceOrder, listingstatus.ActorSystem, 0, "标记异常")
|
||||
}
|
||||
|
||||
@@ -21,7 +21,9 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che
|
||||
order.SettlementStatus = settlementStatusSettled
|
||||
order.SettledAt = &now
|
||||
order.OwnerSettledAt = &now
|
||||
completeAssets(listing, account)
|
||||
if err := completeAssets(tx, listing, account); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
settlement := buildCheckoutSettlement(*order, checkout)
|
||||
if err := appendCheckoutOwnerIncome(tx, order, settlement); err != nil {
|
||||
|
||||
@@ -181,7 +181,9 @@ func (r *Repository) ConfirmPaidFromChannelTx(tx *gorm.DB, orderID uint64) (uint
|
||||
order.Status = orderStatusPendingHandoff
|
||||
order.HandoffStatus = handoffStatusPendingOwner
|
||||
order.HandoffStartedAt = &now
|
||||
markAssetsRented(listing, account)
|
||||
if err := markAssetsRented(tx, listing, account); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// 拉租客进发布群(替代原来的建订单群)
|
||||
conversationID := uint64(0)
|
||||
@@ -275,7 +277,9 @@ func (r *Repository) Cancel(ctx context.Context, userID uint64, orderID uint64)
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
releaseAssetsForRental(listing, account)
|
||||
if err := releaseAssetsForRental(tx, listing, account); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := closePendingOrderPayments(tx, order.ID, "order_cancel"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -149,7 +149,9 @@ func TestArchiveAssetsMovesListingOffline(t *testing.T) {
|
||||
}
|
||||
account := model.GameAccount{Status: accountStatusRented}
|
||||
|
||||
archiveAssets(&listing, &account)
|
||||
if err := archiveAssets(nil, &listing, &account); err != nil {
|
||||
t.Fatalf("archiveAssets: %v", err)
|
||||
}
|
||||
|
||||
if listing.Status != listingStatusOffline {
|
||||
t.Fatalf("listing.Status = %q, want %q", listing.Status, listingStatusOffline)
|
||||
@@ -174,7 +176,9 @@ func TestCompleteAssetsMovesListingCompleted(t *testing.T) {
|
||||
}
|
||||
account := model.GameAccount{Status: accountStatusRented}
|
||||
|
||||
completeAssets(&listing, &account)
|
||||
if err := completeAssets(nil, &listing, &account); err != nil {
|
||||
t.Fatalf("completeAssets: %v", err)
|
||||
}
|
||||
|
||||
if listing.Status != listingStatusCompleted {
|
||||
t.Fatalf("listing.Status = %q, want %q", listing.Status, listingStatusCompleted)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/auditlog"
|
||||
"hfb_sys/backend/internal/listingstatus"
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/notification"
|
||||
"hfb_sys/backend/internal/modules/wallet"
|
||||
@@ -178,11 +179,23 @@ func (r *Repository) Complete(ctx context.Context, pickupID uint64, req Complete
|
||||
}
|
||||
|
||||
// listing 置 completed:listingLockedForOwnerMutation 已拦截此状态再上架。
|
||||
fromStatus := listing.Status
|
||||
listing.InTransaction = true
|
||||
listing.Status = "completed"
|
||||
if err := tx.Save(&listing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := listingstatus.AppendTransition(
|
||||
tx,
|
||||
&listing,
|
||||
fromStatus,
|
||||
listingstatus.SourcePickup,
|
||||
listingstatus.ActorAdmin,
|
||||
0,
|
||||
"管理员提号完成",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := wallet.AppendEntries(tx, wallet.Entry{
|
||||
UserID: pickup.OwnerID,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- +goose Up
|
||||
|
||||
CREATE TABLE IF NOT EXISTS listing_status_events (
|
||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||
listing_id BIGINT UNSIGNED NOT NULL COMMENT '商品ID',
|
||||
owner_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '号主ID',
|
||||
event_type VARCHAR(32) NOT NULL COMMENT '事件类型: published/offline/rented/completed/sealed/abnormal',
|
||||
from_status VARCHAR(32) NOT NULL DEFAULT '' COMMENT '变更前状态',
|
||||
to_status VARCHAR(32) NOT NULL DEFAULT '' COMMENT '变更后状态',
|
||||
source VARCHAR(32) NOT NULL DEFAULT '' COMMENT '来源: seller/admin/review/order/pickup/dispute/system',
|
||||
actor_type VARCHAR(16) NOT NULL DEFAULT '' COMMENT '操作者类型: user/admin/system',
|
||||
actor_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '操作者ID',
|
||||
remark VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事件时间',
|
||||
KEY idx_listing_status_events_day_type (created_at, event_type),
|
||||
KEY idx_listing_status_events_type_source_day (event_type, source, created_at),
|
||||
KEY idx_listing_status_events_listing (listing_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='商品上架/下架/交易离架状态事件';
|
||||
|
||||
-- +goose Down
|
||||
|
||||
DROP TABLE IF EXISTS listing_status_events;
|
||||
@@ -20,6 +20,20 @@ export interface DashboardPending {
|
||||
pending_return_confirms: number
|
||||
}
|
||||
|
||||
export interface ListingDayStats {
|
||||
date: string
|
||||
published_count: number
|
||||
active_offline_count: number
|
||||
trade_leave_count: number
|
||||
}
|
||||
|
||||
export interface ListingDailyOverview {
|
||||
today: ListingDayStats
|
||||
trend: ListingDayStats[]
|
||||
days: number
|
||||
timezone: string
|
||||
}
|
||||
|
||||
export interface DashboardRecentOrder {
|
||||
id: number
|
||||
order_no: string
|
||||
@@ -46,16 +60,32 @@ export interface DashboardRecentDispute {
|
||||
export interface AdminDashboard {
|
||||
metrics: DashboardMetrics
|
||||
pending: DashboardPending
|
||||
listing_daily: ListingDailyOverview
|
||||
recent_orders: DashboardRecentOrder[]
|
||||
recent_disputes: DashboardRecentDispute[]
|
||||
generated_at: string
|
||||
}
|
||||
|
||||
const emptyDayStats = (): ListingDayStats => ({
|
||||
date: '',
|
||||
published_count: 0,
|
||||
active_offline_count: 0,
|
||||
trade_leave_count: 0,
|
||||
})
|
||||
|
||||
export async function fetchAdminDashboard() {
|
||||
const { data } = await apiClient.get<ApiResponse<AdminDashboard>>('/admin/dashboard')
|
||||
const payload = data.data
|
||||
const listingDaily = payload.listing_daily
|
||||
return {
|
||||
...data.data,
|
||||
recent_orders: data.data.recent_orders ?? [],
|
||||
recent_disputes: data.data.recent_disputes ?? [],
|
||||
...payload,
|
||||
listing_daily: {
|
||||
today: listingDaily?.today ?? emptyDayStats(),
|
||||
trend: listingDaily?.trend ?? [],
|
||||
days: listingDaily?.days ?? 7,
|
||||
timezone: listingDaily?.timezone ?? 'Asia/Shanghai',
|
||||
},
|
||||
recent_orders: payload.recent_orders ?? [],
|
||||
recent_disputes: payload.recent_disputes ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
Bell,
|
||||
Bottom,
|
||||
ChatDotRound,
|
||||
Document,
|
||||
Grid,
|
||||
@@ -11,11 +13,17 @@ import {
|
||||
ShoppingBag,
|
||||
Shop,
|
||||
Tickets,
|
||||
Top,
|
||||
User,
|
||||
Van,
|
||||
Wallet,
|
||||
Warning,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { fetchAdminDashboard, type AdminDashboard } from '@/features/admin/api/adminDashboard'
|
||||
import {
|
||||
fetchAdminDashboard,
|
||||
type AdminDashboard,
|
||||
type ListingDayStats,
|
||||
} from '@/features/admin/api/adminDashboard'
|
||||
import { useAdminTable } from '@/features/admin/composables/useAdminTable'
|
||||
import { formatCentWithSymbol } from '@/shared/utils/money'
|
||||
import { adminPath } from '@/shared/utils/adminPath'
|
||||
@@ -30,6 +38,32 @@ const {
|
||||
} = useAdminTable<AdminDashboard>({
|
||||
fetchFn: fetchAdminDashboard,
|
||||
})
|
||||
|
||||
const listingTrend = computed(() => dashboard.value?.listing_daily?.trend ?? [])
|
||||
const listingToday = computed(
|
||||
() =>
|
||||
dashboard.value?.listing_daily?.today ?? {
|
||||
date: '',
|
||||
published_count: 0,
|
||||
active_offline_count: 0,
|
||||
trade_leave_count: 0,
|
||||
}
|
||||
)
|
||||
|
||||
function trendMax(getter: (row: ListingDayStats) => number) {
|
||||
const values = listingTrend.value.map(getter)
|
||||
return Math.max(1, ...values, 0)
|
||||
}
|
||||
|
||||
function barHeight(value: number, max: number) {
|
||||
if (max <= 0) return '8%'
|
||||
return `${Math.max(8, Math.round((value / max) * 100))}%`
|
||||
}
|
||||
|
||||
function shortDate(date: string) {
|
||||
if (!date || date.length < 10) return date
|
||||
return date.slice(5)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -103,6 +137,122 @@ const {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 账号上下架:今日三组 + 近 7 日趋势 -->
|
||||
<div v-if="dashboard" class="dashboard-section listing-daily-section">
|
||||
<h2 class="section-title">
|
||||
<el-icon><Shop /></el-icon>
|
||||
账号上下架
|
||||
<small class="section-subtitle">按北京时间自然日 · 近 {{ dashboard.listing_daily.days }} 日</small>
|
||||
</h2>
|
||||
|
||||
<div class="listing-daily-grid">
|
||||
<div class="listing-stat-card tone-publish">
|
||||
<div class="listing-stat-head">
|
||||
<div class="listing-stat-icon">
|
||||
<el-icon><Top /></el-icon>
|
||||
</div>
|
||||
<div>
|
||||
<span>今日上架</span>
|
||||
<strong>{{ listingToday.published_count }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p class="listing-stat-hint">免审发布 / 审核通过 / 取消订单恢复上架</p>
|
||||
<div class="mini-bars">
|
||||
<div
|
||||
v-for="row in listingTrend"
|
||||
:key="`pub-${row.date}`"
|
||||
class="mini-bar-col"
|
||||
:title="`${row.date}:${row.published_count}`"
|
||||
>
|
||||
<div
|
||||
class="mini-bar publish"
|
||||
:style="{
|
||||
height: barHeight(row.published_count, trendMax(r => r.published_count)),
|
||||
}"
|
||||
/>
|
||||
<em>{{ shortDate(row.date) }}</em>
|
||||
<b>{{ row.published_count }}</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="listing-stat-card tone-offline">
|
||||
<div class="listing-stat-head">
|
||||
<div class="listing-stat-icon">
|
||||
<el-icon><Bottom /></el-icon>
|
||||
</div>
|
||||
<div>
|
||||
<span>今日主动下架</span>
|
||||
<strong>{{ listingToday.active_offline_count }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p class="listing-stat-hint">号主手动下架 + 后台下架</p>
|
||||
<div class="mini-bars">
|
||||
<div
|
||||
v-for="row in listingTrend"
|
||||
:key="`off-${row.date}`"
|
||||
class="mini-bar-col"
|
||||
:title="`${row.date}:${row.active_offline_count}`"
|
||||
>
|
||||
<div
|
||||
class="mini-bar offline"
|
||||
:style="{
|
||||
height: barHeight(
|
||||
row.active_offline_count,
|
||||
trendMax(r => r.active_offline_count)
|
||||
),
|
||||
}"
|
||||
/>
|
||||
<em>{{ shortDate(row.date) }}</em>
|
||||
<b>{{ row.active_offline_count }}</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="listing-stat-card tone-trade">
|
||||
<div class="listing-stat-head">
|
||||
<div class="listing-stat-icon">
|
||||
<el-icon><Van /></el-icon>
|
||||
</div>
|
||||
<div>
|
||||
<span>今日交易离架</span>
|
||||
<strong>{{ listingToday.trade_leave_count }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p class="listing-stat-hint">租出 / 售出完成 / 封存 / 订单仲裁归档</p>
|
||||
<div class="mini-bars">
|
||||
<div
|
||||
v-for="row in listingTrend"
|
||||
:key="`trade-${row.date}`"
|
||||
class="mini-bar-col"
|
||||
:title="`${row.date}:${row.trade_leave_count}`"
|
||||
>
|
||||
<div
|
||||
class="mini-bar trade"
|
||||
:style="{
|
||||
height: barHeight(row.trade_leave_count, trendMax(r => r.trade_leave_count)),
|
||||
}"
|
||||
/>
|
||||
<em>{{ shortDate(row.date) }}</em>
|
||||
<b>{{ row.trade_leave_count }}</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table class="table-panel listing-trend-table" :data="listingTrend" size="small">
|
||||
<el-table-column prop="date" label="日期" min-width="120" />
|
||||
<el-table-column prop="published_count" label="上架" width="100" align="center" />
|
||||
<el-table-column prop="active_offline_count" label="主动下架" width="110" align="center" />
|
||||
<el-table-column prop="trade_leave_count" label="交易离架" width="110" align="center" />
|
||||
<el-table-column label="合计离架" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.active_offline_count + row.trade_leave_count }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 待处理事项 & 快捷入口 -->
|
||||
<div v-if="dashboard" class="dashboard-panels">
|
||||
<div class="dashboard-panel">
|
||||
@@ -500,6 +650,143 @@ const {
|
||||
color: #1b2559;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #a3aed0;
|
||||
}
|
||||
|
||||
.listing-daily-section {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.listing-daily-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.listing-stat-card {
|
||||
padding: 18px 18px 14px;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #eef1f8;
|
||||
box-shadow: 0 4px 14px rgba(27, 37, 89, 0.04);
|
||||
}
|
||||
|
||||
.listing-stat-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.listing-stat-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tone-publish .listing-stat-icon {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.tone-offline .listing-stat-icon {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.tone-trade .listing-stat-icon {
|
||||
background: rgba(79, 124, 255, 0.12);
|
||||
color: #4f7cff;
|
||||
}
|
||||
|
||||
.listing-stat-head span {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: #8f9bba;
|
||||
}
|
||||
|
||||
.listing-stat-head strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: #1b2559;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.listing-stat-hint {
|
||||
margin: 10px 0 14px;
|
||||
font-size: 12px;
|
||||
color: #a3aed0;
|
||||
}
|
||||
|
||||
.mini-bars {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
align-items: end;
|
||||
min-height: 96px;
|
||||
}
|
||||
|
||||
.mini-bar-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mini-bar {
|
||||
width: 100%;
|
||||
max-width: 22px;
|
||||
border-radius: 6px 6px 2px 2px;
|
||||
min-height: 6px;
|
||||
transition: height 0.2s ease;
|
||||
}
|
||||
|
||||
.mini-bar.publish {
|
||||
background: linear-gradient(180deg, #34d399, #10b981);
|
||||
}
|
||||
|
||||
.mini-bar.offline {
|
||||
background: linear-gradient(180deg, #fbbf24, #f59e0b);
|
||||
}
|
||||
|
||||
.mini-bar.trade {
|
||||
background: linear-gradient(180deg, #7aa2ff, #4f7cff);
|
||||
}
|
||||
|
||||
.mini-bar-col em,
|
||||
.mini-bar-col b {
|
||||
font-style: normal;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
color: #a3aed0;
|
||||
}
|
||||
|
||||
.mini-bar-col b {
|
||||
color: #1b2559;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.listing-trend-table {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.listing-daily-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.quick-links {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
|
||||
Reference in New Issue
Block a user