第 5 阶段:纠纷、通知与后台-3
This commit is contained in:
@@ -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`。
|
||||
|
||||
## 文档
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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", "后台仪表盘服务暂时不可用")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -99,3 +99,10 @@
|
||||
- 后台 JWT 与普通用户 JWT 区分 `admin` 和 `user`,普通用户 Token 不能访问 `/api/admin/*`。
|
||||
- 开发态首次后台登录会自动初始化默认管理员:用户名 `admin`,密码 `admin123456`。
|
||||
- 默认管理员只用于本地开发;正式部署前必须改为初始化脚本、强密码和管理员密码修改流程。
|
||||
|
||||
## 开发态后台仪表盘
|
||||
|
||||
- 后台仪表盘接口为 `/api/admin/dashboard`,前端页面为 `/admin/dashboard`。
|
||||
- 当前统计用户数、实名用户数、商品数、上架数、订单数、租赁中订单、今日订单、今日钱包流水。
|
||||
- 待处理事项包括待审核商品、待仲裁申诉、待交接订单和待归还确认订单。
|
||||
- 最近订单和最近申诉用于运营快速定位问题,后续接入后台订单管理和商品审核后再跳转到对应详情页。
|
||||
|
||||
@@ -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<T> {
|
||||
code: string
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export async function fetchAdminDashboard() {
|
||||
const { data } = await apiClient.get<ApiResponse<AdminDashboard>>('/admin/dashboard')
|
||||
return data.data
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { fetchAdminDashboard, type AdminDashboard } from '@/api/adminDashboard'
|
||||
|
||||
const loading = ref(false)
|
||||
const dashboard = ref<AdminDashboard | null>(null)
|
||||
|
||||
onMounted(loadDashboard)
|
||||
|
||||
async function loadDashboard() {
|
||||
loading.value = true
|
||||
try {
|
||||
dashboard.value = await fetchAdminDashboard()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function money(value?: number) {
|
||||
return `¥${Number(value || 0).toFixed(2)}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<section class="page" v-loading="loading">
|
||||
<div class="page-header-row">
|
||||
<div class="page-header">
|
||||
<p class="eyebrow">Admin</p>
|
||||
<h1>后台仪表盘</h1>
|
||||
<p>待审核、待仲裁、订单和流水概览。</p>
|
||||
</div>
|
||||
<el-button @click="loadDashboard">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="dashboard" class="metric-grid dashboard-metrics">
|
||||
<div class="metric-card">
|
||||
<span>用户总数</span>
|
||||
<strong>{{ dashboard.metrics.total_users }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>实名用户</span>
|
||||
<strong>{{ dashboard.metrics.verified_users }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>发布商品</span>
|
||||
<strong>{{ dashboard.metrics.total_listings }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>已上架</span>
|
||||
<strong>{{ dashboard.metrics.published_listings }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>订单总数</span>
|
||||
<strong>{{ dashboard.metrics.total_orders }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>租赁中</span>
|
||||
<strong>{{ dashboard.metrics.renting_orders }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>今日订单</span>
|
||||
<strong>{{ dashboard.metrics.today_orders }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>今日流水</span>
|
||||
<strong>{{ money(dashboard.metrics.today_ledger_amount) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="dashboard" class="dashboard-panels">
|
||||
<div class="order-panel dashboard-panel">
|
||||
<h2>待处理</h2>
|
||||
<RouterLink class="pending-row" to="/admin/disputes">
|
||||
<span>待仲裁申诉</span>
|
||||
<strong>{{ dashboard.pending.disputes }}</strong>
|
||||
</RouterLink>
|
||||
<div class="pending-row">
|
||||
<span>待审核商品</span>
|
||||
<strong>{{ dashboard.pending.listing_reviews }}</strong>
|
||||
</div>
|
||||
<div class="pending-row">
|
||||
<span>待交接订单</span>
|
||||
<strong>{{ dashboard.pending.pending_handoffs }}</strong>
|
||||
</div>
|
||||
<div class="pending-row">
|
||||
<span>待归还确认</span>
|
||||
<strong>{{ dashboard.pending.pending_return_confirms }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="order-panel dashboard-panel">
|
||||
<h2>后台入口</h2>
|
||||
<RouterLink class="pending-row" to="/admin/disputes">
|
||||
<span>仲裁中心</span>
|
||||
<strong>进入</strong>
|
||||
</RouterLink>
|
||||
<RouterLink class="pending-row" to="/admin/system-configs">
|
||||
<span>系统配置</span>
|
||||
<strong>进入</strong>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table v-if="dashboard" class="table-panel" :data="dashboard.recent_orders">
|
||||
<el-table-column prop="order_no" label="最近订单" min-width="210" />
|
||||
<el-table-column prop="title" label="账号" min-width="170" />
|
||||
<el-table-column prop="status" label="状态" width="140" />
|
||||
<el-table-column prop="rent_amount" label="租金" width="100" />
|
||||
<el-table-column prop="deposit_amount" label="押金" width="100" />
|
||||
<el-table-column prop="created_at" label="创建时间" min-width="180" />
|
||||
</el-table>
|
||||
|
||||
<el-table v-if="dashboard" class="table-panel" :data="dashboard.recent_disputes">
|
||||
<el-table-column prop="order_no" label="最近申诉" min-width="210" />
|
||||
<el-table-column prop="title" label="账号" min-width="170" />
|
||||
<el-table-column prop="type" label="类型" width="160" />
|
||||
<el-table-column prop="status" label="状态" width="120" />
|
||||
<el-table-column prop="created_at" label="创建时间" min-width="180" />
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default>
|
||||
<RouterLink to="/admin/disputes">
|
||||
<el-button size="small">处理</el-button>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user