diff --git a/README.md b/README.md index 4e58529..5d8f782 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ npm run dev - 申诉仲裁已支持订单双方发起申诉、开发态后台处理,后台页面为 `http://localhost:5173/admin/disputes`。 - 系统配置已支持默认配置初始化和后台编辑,页面为 `http://localhost:5173/admin/system-configs`,更新会写入审计日志。 - 后台已使用独立登录,页面为 `http://localhost:5173/admin/login`;开发态默认管理员为 `admin / admin123456`。 +- 后台仪表盘已接入真实统计数据,页面为 `http://localhost:5173/admin/dashboard`。 ## 文档 diff --git a/backend/internal/modules/admindashboard/dto.go b/backend/internal/modules/admindashboard/dto.go new file mode 100644 index 0000000..c8584a7 --- /dev/null +++ b/backend/internal/modules/admindashboard/dto.go @@ -0,0 +1,52 @@ +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"` +} + +type MetricsDTO struct { + TotalUsers int64 `json:"total_users"` + VerifiedUsers int64 `json:"verified_users"` + TotalListings int64 `json:"total_listings"` + PublishedListings int64 `json:"published_listings"` + TotalOrders int64 `json:"total_orders"` + RentingOrders int64 `json:"renting_orders"` + TodayOrders int64 `json:"today_orders"` + TodayLedgerAmount float64 `json:"today_ledger_amount"` +} + +type PendingDTO struct { + ListingReviews int64 `json:"listing_reviews"` + Disputes int64 `json:"disputes"` + PendingHandoffs int64 `json:"pending_handoffs"` + PendingReturnConfirms int64 `json:"pending_return_confirms"` +} + +type RecentOrderDTO struct { + ID uint64 `json:"id"` + OrderNo string `json:"order_no"` + Title string `json:"title"` + RenterID uint64 `json:"renter_id"` + OwnerID uint64 `json:"owner_id"` + Status string `json:"status"` + RentAmount float64 `json:"rent_amount"` + DepositAmount float64 `json:"deposit_amount"` + CreatedAt time.Time `json:"created_at"` +} + +type RecentDisputeDTO struct { + ID uint64 `json:"id"` + OrderID uint64 `json:"order_id"` + OrderNo string `json:"order_no"` + Title string `json:"title"` + Type string `json:"type"` + Status string `json:"status"` + InitiatorID uint64 `json:"initiator_id"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/backend/internal/modules/admindashboard/handler.go b/backend/internal/modules/admindashboard/handler.go new file mode 100644 index 0000000..1b24db4 --- /dev/null +++ b/backend/internal/modules/admindashboard/handler.go @@ -0,0 +1,36 @@ +package admindashboard + +import ( + "errors" + "net/http" + + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +func (h *Handler) Summary(c *gin.Context) { + item, err := h.service.Summary() + if err != nil { + writeDashboardError(c, err) + return + } + response.OK(c, item) +} + +func writeDashboardError(c *gin.Context, err error) { + switch { + case errors.Is(err, ErrDependencyUnavailable): + response.ServiceUnavailable(c, "数据库未连接") + default: + response.Error(c, http.StatusInternalServerError, "internal_error", "后台仪表盘服务暂时不可用") + } +} diff --git a/backend/internal/modules/admindashboard/repository.go b/backend/internal/modules/admindashboard/repository.go new file mode 100644 index 0000000..4d81219 --- /dev/null +++ b/backend/internal/modules/admindashboard/repository.go @@ -0,0 +1,102 @@ +package admindashboard + +import ( + "time" + + "hfb_sys/backend/internal/model" + + "gorm.io/gorm" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) Summary() (*DashboardDTO, error) { + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + metrics := MetricsDTO{} + pending := PendingDTO{} + + if err := r.db.Model(&model.User{}).Count(&metrics.TotalUsers).Error; err != nil { + return nil, err + } + if err := r.db.Model(&model.User{}).Where("realname_status = ?", "verified").Count(&metrics.VerifiedUsers).Error; err != nil { + return nil, err + } + if err := r.db.Model(&model.RentalListing{}).Count(&metrics.TotalListings).Error; err != nil { + return nil, err + } + if err := r.db.Model(&model.RentalListing{}).Where("status = ? AND review_status = ?", "published", "approved").Count(&metrics.PublishedListings).Error; err != nil { + return nil, err + } + if err := r.db.Model(&model.RentalOrder{}).Count(&metrics.TotalOrders).Error; err != nil { + return nil, err + } + if err := r.db.Model(&model.RentalOrder{}).Where("status = ?", "renting").Count(&metrics.RentingOrders).Error; err != nil { + return nil, err + } + if err := r.db.Model(&model.RentalOrder{}).Where("created_at >= ?", today).Count(&metrics.TodayOrders).Error; err != nil { + return nil, err + } + if err := r.db.Model(&model.WalletLedger{}). + Select("COALESCE(SUM(amount), 0)"). + Where("created_at >= ?", today). + Scan(&metrics.TodayLedgerAmount).Error; err != nil { + return nil, err + } + if err := r.db.Model(&model.RentalListing{}).Where("review_status = ?", "pending").Count(&pending.ListingReviews).Error; err != nil { + return nil, err + } + if err := r.db.Model(&model.Dispute{}).Where("status IN ?", []string{"open", "processing"}).Count(&pending.Disputes).Error; err != nil { + return nil, err + } + if err := r.db.Model(&model.RentalOrder{}).Where("status = ? AND handoff_status IN ?", "pending_handoff", []string{"pending_owner", "pending_renter_confirm"}).Count(&pending.PendingHandoffs).Error; err != nil { + return nil, err + } + if err := r.db.Model(&model.RentalOrder{}).Where("status = ?", "pending_return_confirm").Count(&pending.PendingReturnConfirms).Error; err != nil { + return nil, err + } + recentOrders, err := r.recentOrders() + if err != nil { + return nil, err + } + recentDisputes, err := r.recentDisputes() + if err != nil { + return nil, err + } + return &DashboardDTO{ + Metrics: metrics, + Pending: pending, + RecentOrders: recentOrders, + RecentDisputes: recentDisputes, + GeneratedAt: now, + }, nil +} + +func (r *Repository) recentOrders() ([]RecentOrderDTO, error) { + var rows []RecentOrderDTO + err := r.db.Table("rental_orders AS o"). + Select("o.id, o.order_no, a.title, o.renter_id, o.owner_id, o.status, o.rent_amount, o.deposit_amount, o.created_at"). + Joins("JOIN game_accounts AS a ON a.id = o.account_id"). + Order("o.id DESC"). + Limit(8). + Scan(&rows).Error + return rows, err +} + +func (r *Repository) recentDisputes() ([]RecentDisputeDTO, error) { + var rows []RecentDisputeDTO + err := r.db.Table("disputes AS d"). + Select("d.id, d.order_id, o.order_no, a.title, d.type, d.status, d.initiator_id, d.created_at"). + Joins("JOIN rental_orders AS o ON o.id = d.order_id"). + Joins("JOIN game_accounts AS a ON a.id = o.account_id"). + Order("d.id DESC"). + Limit(8). + Scan(&rows).Error + return rows, err +} diff --git a/backend/internal/modules/admindashboard/service.go b/backend/internal/modules/admindashboard/service.go new file mode 100644 index 0000000..fd13ae6 --- /dev/null +++ b/backend/internal/modules/admindashboard/service.go @@ -0,0 +1,20 @@ +package admindashboard + +import "errors" + +var ErrDependencyUnavailable = errors.New("dependency unavailable") + +type Service struct { + repo *Repository +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) Summary() (*DashboardDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.Summary() +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index c44932b..954b814 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -5,6 +5,7 @@ import ( "hfb_sys/backend/internal/handler" "hfb_sys/backend/internal/middleware" "hfb_sys/backend/internal/modules/adminauth" + "hfb_sys/backend/internal/modules/admindashboard" "hfb_sys/backend/internal/modules/auth" "hfb_sys/backend/internal/modules/dispute" "hfb_sys/backend/internal/modules/listing" @@ -44,6 +45,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } adminAuthService := adminauth.NewService(adminAuthRepo) adminAuthHandler := adminauth.NewHandler(adminAuthService) + var adminDashboardRepo *admindashboard.Repository + if deps.DB != nil { + adminDashboardRepo = admindashboard.NewRepository(deps.DB) + } + adminDashboardService := admindashboard.NewService(adminDashboardRepo) + adminDashboardHandler := admindashboard.NewHandler(adminDashboardService) userHandler := user.NewHandler(userRepo) var realnameRepo *realname.Repository if deps.DB != nil { @@ -168,6 +175,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { { adminRoutes.GET("/me", adminAuthHandler.Me) adminRoutes.POST("/auth/logout", adminAuthHandler.Logout) + adminRoutes.GET("/dashboard", adminDashboardHandler.Summary) adminRoutes.GET("/disputes", disputeHandler.AdminList) adminRoutes.POST("/disputes/:id/arbitrate", disputeHandler.AdminArbitrate) adminRoutes.GET("/system-configs", systemConfigHandler.List) diff --git a/docs/api.md b/docs/api.md index a7b9ec8..b8b0cce 100644 --- a/docs/api.md +++ b/docs/api.md @@ -49,6 +49,7 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。 - `POST /api/admin/auth/login` - `POST /api/admin/auth/logout` - `GET /api/admin/me` +- `GET /api/admin/dashboard` - `GET /api/admin/disputes` - `POST /api/admin/disputes/{id}/arbitrate` - `GET /api/admin/system-configs` diff --git a/docs/business-rules.md b/docs/business-rules.md index 8a45aa7..d190d45 100644 --- a/docs/business-rules.md +++ b/docs/business-rules.md @@ -99,3 +99,10 @@ - 后台 JWT 与普通用户 JWT 区分 `admin` 和 `user`,普通用户 Token 不能访问 `/api/admin/*`。 - 开发态首次后台登录会自动初始化默认管理员:用户名 `admin`,密码 `admin123456`。 - 默认管理员只用于本地开发;正式部署前必须改为初始化脚本、强密码和管理员密码修改流程。 + +## 开发态后台仪表盘 + +- 后台仪表盘接口为 `/api/admin/dashboard`,前端页面为 `/admin/dashboard`。 +- 当前统计用户数、实名用户数、商品数、上架数、订单数、租赁中订单、今日订单、今日钱包流水。 +- 待处理事项包括待审核商品、待仲裁申诉、待交接订单和待归还确认订单。 +- 最近订单和最近申诉用于运营快速定位问题,后续接入后台订单管理和商品审核后再跳转到对应详情页。 diff --git a/frontend/src/api/adminDashboard.ts b/frontend/src/api/adminDashboard.ts new file mode 100644 index 0000000..8f66b79 --- /dev/null +++ b/frontend/src/api/adminDashboard.ts @@ -0,0 +1,61 @@ +import { apiClient } from './client' + +export interface DashboardMetrics { + total_users: number + verified_users: number + total_listings: number + published_listings: number + total_orders: number + renting_orders: number + today_orders: number + today_ledger_amount: number +} + +export interface DashboardPending { + listing_reviews: number + disputes: number + pending_handoffs: number + pending_return_confirms: number +} + +export interface DashboardRecentOrder { + id: number + order_no: string + title: string + renter_id: number + owner_id: number + status: string + rent_amount: number + deposit_amount: number + created_at: string +} + +export interface DashboardRecentDispute { + id: number + order_id: number + order_no: string + title: string + type: string + status: string + initiator_id: number + created_at: string +} + +export interface AdminDashboard { + metrics: DashboardMetrics + pending: DashboardPending + recent_orders: DashboardRecentOrder[] + recent_disputes: DashboardRecentDispute[] + generated_at: string +} + +interface ApiResponse { + code: string + message: string + data: T +} + +export async function fetchAdminDashboard() { + const { data } = await apiClient.get>('/admin/dashboard') + return data.data +} diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index e43d4c8..ad6a1b9 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -135,6 +135,38 @@ h1 { font-size: 20px; } +.dashboard-metrics { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.dashboard-panels { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.dashboard-panel { + max-width: none; +} + +.pending-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 40px; + border-top: 1px solid #e4e7ed; + color: #52616f; +} + +.pending-row:first-of-type { + border-top: 0; +} + +.pending-row strong { + color: #111827; +} + .login-form { max-width: 420px; margin-top: 28px; @@ -399,6 +431,8 @@ h1 { .listing-grid, .detail-grid, + .dashboard-metrics, + .dashboard-panels, .form-grid { grid-template-columns: 1fr; } diff --git a/frontend/src/views/admin/AdminDashboardView.vue b/frontend/src/views/admin/AdminDashboardView.vue index 95354fb..ebdabb0 100644 --- a/frontend/src/views/admin/AdminDashboardView.vue +++ b/frontend/src/views/admin/AdminDashboardView.vue @@ -1,9 +1,129 @@ + +