后台“资金流水

This commit is contained in:
yml
2026-05-22 17:43:45 +08:00
parent 7df557d668
commit c9e6ea2b71
28 changed files with 1271 additions and 34 deletions
+3
View File
@@ -34,11 +34,14 @@ npm run dev
- 账号交接已支持号主提交说明、租客确认收号,确认后订单进入租赁中。 - 账号交接已支持号主提交说明、租客确认收号,确认后订单进入租赁中。
- 归还流程已支持租客提交归还、号主确认归还,完成后账号重新上架。 - 归还流程已支持租客提交归还、号主确认归还,完成后账号重新上架。
- 钱包账务当前为开发态模拟流水,可通过 `GET /api/wallet/balance``GET /api/wallet/ledger` 查看。 - 钱包账务当前为开发态模拟流水,可通过 `GET /api/wallet/balance``GET /api/wallet/ledger` 查看。
- 后台资金流水已接入,页面为 `http://localhost:5173/admin/wallet-ledger`,支持按用户、订单和业务类型查询。
- 站内信已支持订单关键节点自动写入,可通过 `GET /api/notifications` 查看。 - 站内信已支持订单关键节点自动写入,可通过 `GET /api/notifications` 查看。
- 申诉仲裁已支持订单双方发起申诉、开发态后台处理,后台页面为 `http://localhost:5173/admin/disputes` - 申诉仲裁已支持订单双方发起申诉、开发态后台处理,后台页面为 `http://localhost:5173/admin/disputes`
- 系统配置已支持默认配置初始化和后台编辑,页面为 `http://localhost:5173/admin/system-configs`,更新会写入审计日志。 - 系统配置已支持默认配置初始化和后台编辑,页面为 `http://localhost:5173/admin/system-configs`,更新会写入审计日志。
- 后台已使用独立登录、图形验证码和独立管理 UI,页面为 `http://localhost:5173/admin/login`;开发态默认管理员为 `admin / admin123456` - 后台已使用独立登录、图形验证码和独立管理 UI,页面为 `http://localhost:5173/admin/login`;开发态默认管理员为 `admin / admin123456`
- 后台仪表盘已接入真实统计数据,页面为 `http://localhost:5173/admin/dashboard` - 后台仪表盘已接入真实统计数据,页面为 `http://localhost:5173/admin/dashboard`
- 用户管理后台已接入,页面为 `http://localhost:5173/admin/users`,支持冻结和解冻用户。
- 订单管理后台已接入,页面为 `http://localhost:5173/admin/orders`,支持查看全量订单和交接记录。
- 商品审核后台已接入,页面为 `http://localhost:5173/admin/listings/review` - 商品审核后台已接入,页面为 `http://localhost:5173/admin/listings/review`
## 文档 ## 文档
+23
View File
@@ -0,0 +1,23 @@
package adminuser
import "time"
type UserDTO struct {
ID uint64 `json:"id"`
Phone string `json:"phone"`
Nickname string `json:"nickname"`
RealnameStatus string `json:"realname_status"`
RiskStatus string `json:"risk_status"`
CreditScore int `json:"credit_score"`
Status string `json:"status"`
OrderCount int64 `json:"order_count"`
ListingCount int64 `json:"listing_count"`
DisputeCount int64 `json:"dispute_count"`
LastLoginAt *time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type FreezeRequest struct {
Reason string `json:"reason"`
}
@@ -0,0 +1,105 @@
package adminuser
import (
"errors"
"net/http"
"strconv"
"hfb_sys/backend/internal/middleware"
"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) List(c *gin.Context) {
items, err := h.service.List()
if err != nil {
writeAdminUserError(c, err)
return
}
response.OK(c, gin.H{"items": items})
}
func (h *Handler) Freeze(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
userID, ok := parseID(c)
if !ok {
return
}
var req FreezeRequest
_ = c.ShouldBindJSON(&req)
item, err := h.service.Freeze(adminID, userID, req, auditMeta(c))
if err != nil {
writeAdminUserError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) Unfreeze(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
userID, ok := parseID(c)
if !ok {
return
}
item, err := h.service.Unfreeze(adminID, userID, auditMeta(c))
if err != nil {
writeAdminUserError(c, err)
return
}
response.OK(c, item)
}
func currentAdminID(c *gin.Context) (uint64, bool) {
value, ok := c.Get(middleware.ContextAdminID)
if !ok {
return 0, false
}
adminID, ok := value.(uint64)
return adminID, ok
}
func parseID(c *gin.Context) (uint64, bool) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
response.BadRequest(c, "ID 不正确")
return 0, false
}
return id, true
}
func auditMeta(c *gin.Context) AuditMeta {
return AuditMeta{
IP: c.ClientIP(),
UserAgent: c.GetHeader("User-Agent"),
}
}
func writeAdminUserError(c *gin.Context, err error) {
switch {
case errors.Is(err, ErrDependencyUnavailable):
response.ServiceUnavailable(c, "数据库未连接")
case errors.Is(err, ErrInvalidUser):
response.BadRequest(c, "用户不符合规则")
case IsNotFound(err):
response.Error(c, http.StatusNotFound, "not_found", "用户不存在")
default:
response.Error(c, http.StatusInternalServerError, "internal_error", "用户管理服务暂时不可用")
}
}
@@ -0,0 +1,150 @@
package adminuser
import (
"encoding/json"
"errors"
"hfb_sys/backend/internal/model"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Repository struct {
db *gorm.DB
}
type AuditMeta struct {
IP string
UserAgent string
}
func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) List() ([]UserDTO, error) {
var rows []userRow
err := r.db.Table("users AS u").
Select(`u.*,
COALESCE(o.order_count, 0) AS order_count,
COALESCE(l.listing_count, 0) AS listing_count,
COALESCE(d.dispute_count, 0) AS dispute_count`).
Joins("LEFT JOIN (SELECT user_id, COUNT(*) AS order_count FROM (SELECT renter_id AS user_id FROM rental_orders UNION ALL SELECT owner_id AS user_id FROM rental_orders) AS order_users GROUP BY user_id) AS o ON o.user_id = u.id").
Joins("LEFT JOIN (SELECT owner_id AS user_id, COUNT(*) AS listing_count FROM rental_listings GROUP BY owner_id) AS l ON l.user_id = u.id").
Joins("LEFT JOIN (SELECT user_id, COUNT(*) AS dispute_count FROM (SELECT initiator_id AS user_id FROM disputes UNION ALL SELECT target_user_id AS user_id FROM disputes) AS dispute_users GROUP BY user_id) AS d ON d.user_id = u.id").
Order("u.id DESC").
Limit(200).
Scan(&rows).Error
if err != nil {
return nil, err
}
items := make([]UserDTO, 0, len(rows))
for _, row := range rows {
items = append(items, row.toDTO())
}
return items, nil
}
func (r *Repository) Freeze(adminID uint64, userID uint64, req FreezeRequest, meta AuditMeta) (*UserDTO, error) {
return r.updateStatus(adminID, userID, "frozen", "frozen", "admin_user.freeze", req.Reason, meta)
}
func (r *Repository) Unfreeze(adminID uint64, userID uint64, meta AuditMeta) (*UserDTO, error) {
return r.updateStatus(adminID, userID, "active", "normal", "admin_user.unfreeze", "", meta)
}
func (r *Repository) updateStatus(adminID uint64, userID uint64, status string, riskStatus string, action string, reason string, meta AuditMeta) (*UserDTO, error) {
err := r.db.Transaction(func(tx *gorm.DB) error {
var user model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil {
return err
}
beforeStatus := user.Status
beforeRisk := user.RiskStatus
user.Status = status
user.RiskStatus = riskStatus
if err := tx.Save(&user).Error; err != nil {
return err
}
return appendAuditLog(tx, adminID, action, user.ID, meta, map[string]any{
"user_id": user.ID,
"reason": reason,
"before_status": beforeStatus,
"after_status": status,
"before_risk_status": beforeRisk,
"after_risk_status": riskStatus,
})
})
if err != nil {
return nil, err
}
return r.Find(userID)
}
func (r *Repository) Find(userID uint64) (*UserDTO, error) {
var row userRow
err := r.db.Table("users AS u").
Select(`u.*,
COALESCE(o.order_count, 0) AS order_count,
COALESCE(l.listing_count, 0) AS listing_count,
COALESCE(d.dispute_count, 0) AS dispute_count`).
Joins("LEFT JOIN (SELECT user_id, COUNT(*) AS order_count FROM (SELECT renter_id AS user_id FROM rental_orders UNION ALL SELECT owner_id AS user_id FROM rental_orders) AS order_users GROUP BY user_id) AS o ON o.user_id = u.id").
Joins("LEFT JOIN (SELECT owner_id AS user_id, COUNT(*) AS listing_count FROM rental_listings GROUP BY owner_id) AS l ON l.user_id = u.id").
Joins("LEFT JOIN (SELECT user_id, COUNT(*) AS dispute_count FROM (SELECT initiator_id AS user_id FROM disputes UNION ALL SELECT target_user_id AS user_id FROM disputes) AS dispute_users GROUP BY user_id) AS d ON d.user_id = u.id").
Where("u.id = ?", userID).
First(&row).Error
if err != nil {
return nil, err
}
dto := row.toDTO()
return &dto, nil
}
type userRow struct {
model.User
OrderCount int64
ListingCount int64
DisputeCount int64
}
func (row userRow) toDTO() UserDTO {
return UserDTO{
ID: row.ID,
Phone: row.Phone,
Nickname: row.Nickname,
RealnameStatus: row.RealnameStatus,
RiskStatus: row.RiskStatus,
CreditScore: row.CreditScore,
Status: row.Status,
OrderCount: row.OrderCount,
ListingCount: row.ListingCount,
DisputeCount: row.DisputeCount,
LastLoginAt: row.LastLoginAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
}
func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizID uint64, meta AuditMeta, detail map[string]any) error {
raw, err := json.Marshal(detail)
if err != nil {
return err
}
row := model.AuditLog{
ActorType: "admin",
ActorID: actorID,
Action: action,
BizType: "user",
BizID: &bizID,
IP: meta.IP,
UserAgent: meta.UserAgent,
Detail: datatypes.JSON(raw),
}
return tx.Create(&row).Error
}
func IsNotFound(err error) bool {
return errors.Is(err, gorm.ErrRecordNotFound)
}
@@ -0,0 +1,43 @@
package adminuser
import "errors"
var (
ErrDependencyUnavailable = errors.New("dependency unavailable")
ErrInvalidUser = errors.New("invalid user")
)
type Service struct {
repo *Repository
}
func NewService(repo *Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) List() ([]UserDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.List()
}
func (s *Service) Freeze(adminID uint64, userID uint64, req FreezeRequest, meta AuditMeta) (*UserDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if userID == 0 {
return nil, ErrInvalidUser
}
return s.repo.Freeze(adminID, userID, req, meta)
}
func (s *Service) Unfreeze(adminID uint64, userID uint64, meta AuditMeta) (*UserDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if userID == 0 {
return nil, ErrInvalidUser
}
return s.repo.Unfreeze(adminID, userID, meta)
}
+2
View File
@@ -13,6 +13,8 @@ type OrderDTO struct {
AccountID uint64 `json:"account_id"` AccountID uint64 `json:"account_id"`
OwnerID uint64 `json:"owner_id"` OwnerID uint64 `json:"owner_id"`
RenterID uint64 `json:"renter_id"` RenterID uint64 `json:"renter_id"`
OwnerPhone string `json:"owner_phone,omitempty"`
RenterPhone string `json:"renter_phone,omitempty"`
Title string `json:"title"` Title string `json:"title"`
ServerRegion string `json:"server_region"` ServerRegion string `json:"server_region"`
LoginPlatform string `json:"login_platform"` LoginPlatform string `json:"login_platform"`
+35
View File
@@ -52,6 +52,41 @@ func (h *Handler) List(c *gin.Context) {
response.OK(c, gin.H{"items": items}) response.OK(c, gin.H{"items": items})
} }
func (h *Handler) AdminList(c *gin.Context) {
items, err := h.service.ListAdmin()
if err != nil {
writeOrderError(c, err)
return
}
response.OK(c, gin.H{"items": items})
}
func (h *Handler) AdminDetail(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
item, err := h.service.FindAdmin(id)
if err != nil {
writeOrderError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) AdminHandoffRecords(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
items, err := h.service.HandoffRecordsAdmin(id)
if err != nil {
writeOrderError(c, err)
return
}
response.OK(c, gin.H{"items": items})
}
func (h *Handler) Detail(c *gin.Context) { func (h *Handler) Detail(c *gin.Context) {
userID, ok := currentUserID(c) userID, ok := currentUserID(c)
if !ok { if !ok {
@@ -462,6 +462,47 @@ func (r *Repository) ListForUser(userID uint64) ([]OrderDTO, error) {
return items, nil return items, nil
} }
func (r *Repository) ListAdmin() ([]OrderDTO, error) {
var rows []orderRow
err := r.adminQuery().
Order("o.id DESC").
Limit(200).
Scan(&rows).Error
if err != nil {
return nil, err
}
items := make([]OrderDTO, 0, len(rows))
for _, row := range rows {
items = append(items, row.toDTO())
}
return items, nil
}
func (r *Repository) FindAdmin(orderID uint64) (*OrderDTO, error) {
var row orderRow
if err := r.adminQuery().Where("o.id = ?", orderID).First(&row).Error; err != nil {
return nil, err
}
dto := row.toDTO()
return &dto, nil
}
func (r *Repository) HandoffRecordsAdmin(orderID uint64) ([]HandoffRecordDTO, error) {
var order model.RentalOrder
if err := r.db.First(&order, orderID).Error; err != nil {
return nil, err
}
var records []model.HandoffRecord
if err := r.db.Where("order_id = ?", orderID).Order("id ASC").Find(&records).Error; err != nil {
return nil, err
}
items := make([]HandoffRecordDTO, 0, len(records))
for _, record := range records {
items = append(items, toHandoffDTO(record))
}
return items, nil
}
func (r *Repository) FindForUser(userID uint64, orderID uint64) (*OrderDTO, error) { func (r *Repository) FindForUser(userID uint64, orderID uint64) (*OrderDTO, error) {
var row orderRow var row orderRow
if err := r.baseQuery(). if err := r.baseQuery().
@@ -488,11 +529,21 @@ func (r *Repository) baseQuery() *gorm.DB {
Joins("JOIN game_accounts AS a ON a.id = o.account_id") Joins("JOIN game_accounts AS a ON a.id = o.account_id")
} }
func (r *Repository) adminQuery() *gorm.DB {
return r.db.Table("rental_orders AS o").
Select("o.*, a.title, a.server_region, a.login_platform, owner.phone AS owner_phone, renter.phone AS renter_phone").
Joins("JOIN game_accounts AS a ON a.id = o.account_id").
Joins("JOIN users AS owner ON owner.id = o.owner_id").
Joins("JOIN users AS renter ON renter.id = o.renter_id")
}
type orderRow struct { type orderRow struct {
model.RentalOrder model.RentalOrder
Title string Title string
ServerRegion string ServerRegion string
LoginPlatform string LoginPlatform string
OwnerPhone string
RenterPhone string
} }
func (row orderRow) toDTO() OrderDTO { func (row orderRow) toDTO() OrderDTO {
@@ -503,6 +554,8 @@ func (row orderRow) toDTO() OrderDTO {
AccountID: row.AccountID, AccountID: row.AccountID,
OwnerID: row.OwnerID, OwnerID: row.OwnerID,
RenterID: row.RenterID, RenterID: row.RenterID,
OwnerPhone: row.OwnerPhone,
RenterPhone: row.RenterPhone,
Title: row.Title, Title: row.Title,
ServerRegion: row.ServerRegion, ServerRegion: row.ServerRegion,
LoginPlatform: row.LoginPlatform, LoginPlatform: row.LoginPlatform,
+21
View File
@@ -88,6 +88,27 @@ func (s *Service) ListForUser(userID uint64) ([]OrderDTO, error) {
return s.repo.ListForUser(userID) return s.repo.ListForUser(userID)
} }
func (s *Service) ListAdmin() ([]OrderDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.ListAdmin()
}
func (s *Service) FindAdmin(orderID uint64) (*OrderDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.FindAdmin(orderID)
}
func (s *Service) HandoffRecordsAdmin(orderID uint64) ([]HandoffRecordDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.HandoffRecordsAdmin(orderID)
}
func (s *Service) FindForUser(userID uint64, orderID uint64) (*OrderDTO, error) { func (s *Service) FindForUser(userID uint64, orderID uint64) (*OrderDTO, error) {
if s.repo == nil { if s.repo == nil {
return nil, ErrDependencyUnavailable return nil, ErrDependencyUnavailable
+25
View File
@@ -23,3 +23,28 @@ type LedgerDTO struct {
Remark string `json:"remark"` Remark string `json:"remark"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
type AdminLedgerQuery struct {
UserID uint64
OrderID uint64
BizType string
Limit int
}
type AdminLedgerDTO struct {
ID uint64 `json:"id"`
LedgerNo string `json:"ledger_no"`
UserID uint64 `json:"user_id"`
UserPhone string `json:"user_phone"`
UserNickname string `json:"user_nickname"`
OrderID *uint64 `json:"order_id"`
OrderNo string `json:"order_no"`
Direction string `json:"direction"`
Amount float64 `json:"amount"`
BalanceAfter float64 `json:"balance_after"`
BalanceType string `json:"balance_type"`
BizType string `json:"biz_type"`
BizNo string `json:"biz_no"`
Remark string `json:"remark"`
CreatedAt time.Time `json:"created_at"`
}
@@ -2,6 +2,7 @@ package wallet
import ( import (
"errors" "errors"
"strconv"
"hfb_sys/backend/internal/middleware" "hfb_sys/backend/internal/middleware"
"hfb_sys/backend/pkg/response" "hfb_sys/backend/pkg/response"
@@ -45,6 +46,49 @@ func (h *Handler) Ledger(c *gin.Context) {
response.OK(c, gin.H{"items": items}) response.OK(c, gin.H{"items": items})
} }
func (h *Handler) AdminLedger(c *gin.Context) {
query, ok := parseAdminLedgerQuery(c)
if !ok {
return
}
items, err := h.service.AdminLedger(query)
if err != nil {
writeWalletError(c, err)
return
}
response.OK(c, gin.H{"items": items})
}
func parseAdminLedgerQuery(c *gin.Context) (AdminLedgerQuery, bool) {
var query AdminLedgerQuery
if raw := c.Query("user_id"); raw != "" {
value, err := strconv.ParseUint(raw, 10, 64)
if err != nil || value == 0 {
response.BadRequest(c, "用户 ID 不正确")
return query, false
}
query.UserID = value
}
if raw := c.Query("order_id"); raw != "" {
value, err := strconv.ParseUint(raw, 10, 64)
if err != nil || value == 0 {
response.BadRequest(c, "订单 ID 不正确")
return query, false
}
query.OrderID = value
}
if raw := c.Query("limit"); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil || value <= 0 {
response.BadRequest(c, "查询条数不正确")
return query, false
}
query.Limit = value
}
query.BizType = c.Query("biz_type")
return query, true
}
func currentUserID(c *gin.Context) (uint64, bool) { func currentUserID(c *gin.Context) (uint64, bool) {
value, ok := c.Get(middleware.ContextUserID) value, ok := c.Get(middleware.ContextUserID)
if !ok { if !ok {
@@ -57,6 +57,37 @@ func (r *Repository) Ledger(userID uint64) ([]LedgerDTO, error) {
return items, nil return items, nil
} }
func (r *Repository) AdminLedger(query AdminLedgerQuery) ([]AdminLedgerDTO, error) {
limit := query.Limit
if limit <= 0 || limit > 500 {
limit = 200
}
db := r.db.Table("wallet_ledger AS wl").
Select(`wl.id, wl.ledger_no, wl.user_id, COALESCE(u.phone, '') AS user_phone,
COALESCE(u.nickname, '') AS user_nickname, wl.order_id, COALESCE(ro.order_no, '') AS order_no,
wl.direction, wl.amount, wl.balance_after, wl.balance_type, wl.biz_type, wl.biz_no,
wl.remark, wl.created_at`).
Joins("LEFT JOIN users AS u ON u.id = wl.user_id").
Joins("LEFT JOIN rental_orders AS ro ON ro.id = wl.order_id")
if query.UserID > 0 {
db = db.Where("wl.user_id = ?", query.UserID)
}
if query.OrderID > 0 {
db = db.Where("wl.order_id = ?", query.OrderID)
}
if query.BizType != "" {
db = db.Where("wl.biz_type = ?", query.BizType)
}
var items []AdminLedgerDTO
if err := db.Order("wl.id DESC").Limit(limit).Scan(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func AppendEntries(tx *gorm.DB, entries ...Entry) error { func AppendEntries(tx *gorm.DB, entries ...Entry) error {
for _, entry := range entries { for _, entry := range entries {
if entry.Amount <= 0 { if entry.Amount <= 0 {
@@ -25,3 +25,10 @@ func (s *Service) Ledger(userID uint64) ([]LedgerDTO, error) {
} }
return s.repo.Ledger(userID) return s.repo.Ledger(userID)
} }
func (s *Service) AdminLedger(query AdminLedgerQuery) ([]AdminLedgerDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.AdminLedger(query)
}
+14
View File
@@ -6,6 +6,7 @@ import (
"hfb_sys/backend/internal/middleware" "hfb_sys/backend/internal/middleware"
"hfb_sys/backend/internal/modules/adminauth" "hfb_sys/backend/internal/modules/adminauth"
"hfb_sys/backend/internal/modules/admindashboard" "hfb_sys/backend/internal/modules/admindashboard"
"hfb_sys/backend/internal/modules/adminuser"
"hfb_sys/backend/internal/modules/auth" "hfb_sys/backend/internal/modules/auth"
"hfb_sys/backend/internal/modules/dispute" "hfb_sys/backend/internal/modules/dispute"
"hfb_sys/backend/internal/modules/listing" "hfb_sys/backend/internal/modules/listing"
@@ -51,6 +52,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
} }
adminDashboardService := admindashboard.NewService(adminDashboardRepo) adminDashboardService := admindashboard.NewService(adminDashboardRepo)
adminDashboardHandler := admindashboard.NewHandler(adminDashboardService) adminDashboardHandler := admindashboard.NewHandler(adminDashboardService)
var adminUserRepo *adminuser.Repository
if deps.DB != nil {
adminUserRepo = adminuser.NewRepository(deps.DB)
}
adminUserService := adminuser.NewService(adminUserRepo)
adminUserHandler := adminuser.NewHandler(adminUserService)
userHandler := user.NewHandler(userRepo) userHandler := user.NewHandler(userRepo)
var realnameRepo *realname.Repository var realnameRepo *realname.Repository
if deps.DB != nil { if deps.DB != nil {
@@ -177,11 +184,18 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
adminRoutes.GET("/me", adminAuthHandler.Me) adminRoutes.GET("/me", adminAuthHandler.Me)
adminRoutes.POST("/auth/logout", adminAuthHandler.Logout) adminRoutes.POST("/auth/logout", adminAuthHandler.Logout)
adminRoutes.GET("/dashboard", adminDashboardHandler.Summary) adminRoutes.GET("/dashboard", adminDashboardHandler.Summary)
adminRoutes.GET("/users", adminUserHandler.List)
adminRoutes.POST("/users/:id/freeze", adminUserHandler.Freeze)
adminRoutes.POST("/users/:id/unfreeze", adminUserHandler.Unfreeze)
adminRoutes.GET("/orders", orderHandler.AdminList)
adminRoutes.GET("/orders/:id", orderHandler.AdminDetail)
adminRoutes.GET("/orders/:id/handoff-records", orderHandler.AdminHandoffRecords)
adminRoutes.GET("/listings/pending", listingHandler.ListPendingReview) adminRoutes.GET("/listings/pending", listingHandler.ListPendingReview)
adminRoutes.POST("/listings/:id/approve", listingHandler.Approve) adminRoutes.POST("/listings/:id/approve", listingHandler.Approve)
adminRoutes.POST("/listings/:id/reject", listingHandler.Reject) adminRoutes.POST("/listings/:id/reject", listingHandler.Reject)
adminRoutes.GET("/disputes", disputeHandler.AdminList) adminRoutes.GET("/disputes", disputeHandler.AdminList)
adminRoutes.POST("/disputes/:id/arbitrate", disputeHandler.AdminArbitrate) adminRoutes.POST("/disputes/:id/arbitrate", disputeHandler.AdminArbitrate)
adminRoutes.GET("/wallet/ledger", walletHandler.AdminLedger)
adminRoutes.GET("/system-configs", systemConfigHandler.List) adminRoutes.GET("/system-configs", systemConfigHandler.List)
adminRoutes.PUT("/system-configs/:key", systemConfigHandler.Update) adminRoutes.PUT("/system-configs/:key", systemConfigHandler.Update)
} }
+8 -1
View File
@@ -51,12 +51,19 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。
- `POST /api/admin/auth/logout` - `POST /api/admin/auth/logout`
- `GET /api/admin/me` - `GET /api/admin/me`
- `GET /api/admin/dashboard` - `GET /api/admin/dashboard`
- `GET /api/admin/users`
- `POST /api/admin/users/{id}/freeze`
- `POST /api/admin/users/{id}/unfreeze`
- `GET /api/admin/orders`
- `GET /api/admin/orders/{id}`
- `GET /api/admin/orders/{id}/handoff-records`
- `GET /api/admin/listings/pending` - `GET /api/admin/listings/pending`
- `POST /api/admin/listings/{id}/approve` - `POST /api/admin/listings/{id}/approve`
- `POST /api/admin/listings/{id}/reject` - `POST /api/admin/listings/{id}/reject`
- `GET /api/admin/disputes` - `GET /api/admin/disputes`
- `POST /api/admin/disputes/{id}/arbitrate` - `POST /api/admin/disputes/{id}/arbitrate`
- `GET /api/admin/wallet/ledger`
- `GET /api/admin/system-configs` - `GET /api/admin/system-configs`
- `PUT /api/admin/system-configs/{key}` - `PUT /api/admin/system-configs/{key}`
说明:`/api/admin/*` 当前是开发态 mock 后台接口,只要求登录,后续接入后台管理员账号、RBAC 和 Casbin 权限后再收紧访问控制。 说明:`GET /api/admin/wallet/ledger` 支持按 `user_id``order_id``biz_type``limit` 查询最近资金流水。`/api/admin/*` 当前已使用独立后台登录,后续接入 RBAC 和 Casbin 权限后再按角色收紧访问控制。
+17
View File
@@ -64,6 +64,8 @@
- 取消待交接订单时释放租客冻结金额。 - 取消待交接订单时释放租客冻结金额。
- 订单完成时释放租客冻结金额,给号主生成租金入账流水,并给租客生成押金退回流水。 - 订单完成时释放租客冻结金额,给号主生成租金入账流水,并给租客生成押金退回流水。
- 每笔流水记录 `balance_after`,用于后续对账。 - 每笔流水记录 `balance_after`,用于后续对账。
- 后台资金流水接口为 `/api/admin/wallet/ledger`,前端页面为 `/admin/wallet-ledger`
- 后台可按用户、订单和业务类型查看最近流水,用于客服核对押金、租金、释放和结算记录。
- 后续接入真实支付后,需要把模拟冻结替换为支付成功后的真实冻结。 - 后续接入真实支付后,需要把模拟冻结替换为支付成功后的真实冻结。
## 开发态站内信 ## 开发态站内信
@@ -111,3 +113,18 @@
- 当前统计用户数、实名用户数、商品数、上架数、订单数、租赁中订单、今日订单、今日钱包流水。 - 当前统计用户数、实名用户数、商品数、上架数、订单数、租赁中订单、今日订单、今日钱包流水。
- 待处理事项包括待审核商品、待仲裁申诉、待交接订单和待归还确认订单。 - 待处理事项包括待审核商品、待仲裁申诉、待交接订单和待归还确认订单。
- 最近订单和最近申诉用于运营快速定位问题,后续接入后台订单管理和商品审核后再跳转到对应详情页。 - 最近订单和最近申诉用于运营快速定位问题,后续接入后台订单管理和商品审核后再跳转到对应详情页。
## 开发态用户管理
- 用户管理接口为 `/api/admin/users`,前端页面为 `/admin/users`
- 后台可查看用户手机号、实名状态、风险状态、信用分、订单数、发布数和申诉数。
- 冻结用户会将 `users.status` 改为 `frozen``risk_status` 改为 `frozen`,被冻结用户不能继续登录。
- 解冻用户会将 `users.status` 改为 `active``risk_status` 改为 `normal`
- 冻结和解冻都会写入 `audit_logs`,记录管理员、用户、前后状态和原因。
## 开发态订单管理
- 订单管理接口为 `/api/admin/orders`,前端页面为 `/admin/orders`
- 后台可查看全量订单、租客、号主、订单状态、交接状态、结算状态、租金和押金。
- 订单详情页 `/admin/orders/:id` 展示订单状态、双方用户、租期、交接记录和账号资产快照。
- 当前后台订单管理先做只读能力,后续再补后台关闭订单、标记异常和客服介入操作。
+38
View File
@@ -0,0 +1,38 @@
import { apiClient } from './client'
export interface AdminUserItem {
id: number
phone: string
nickname: string
realname_status: string
risk_status: string
credit_score: number
status: string
order_count: number
listing_count: number
dispute_count: number
last_login_at?: string
created_at: string
updated_at: string
}
interface ApiResponse<T> {
code: string
message: string
data: T
}
export async function fetchAdminUsers() {
const { data } = await apiClient.get<ApiResponse<{ items: AdminUserItem[] }>>('/admin/users')
return data.data.items
}
export async function freezeAdminUser(id: number, reason: string) {
const { data } = await apiClient.post<ApiResponse<AdminUserItem>>(`/admin/users/${id}/freeze`, { reason })
return data.data
}
export async function unfreezeAdminUser(id: number) {
const { data } = await apiClient.post<ApiResponse<AdminUserItem>>(`/admin/users/${id}/unfreeze`)
return data.data
}
+38
View File
@@ -0,0 +1,38 @@
import { apiClient } from './client'
export interface AdminWalletLedger {
id: number
ledger_no: string
user_id: number
user_phone: string
user_nickname: string
order_id?: number
order_no: string
direction: string
amount: number
balance_after: number
balance_type: string
biz_type: string
biz_no: string
remark: string
created_at: string
}
export interface AdminWalletLedgerQuery {
user_id?: string
order_id?: string
biz_type?: string
limit?: number
}
interface ApiResponse<T> {
code: string
message: string
data: T
}
export async function fetchAdminWalletLedger(query: AdminWalletLedgerQuery = {}) {
const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined))
const { data } = await apiClient.get<ApiResponse<{ items: AdminWalletLedger[] }>>('/admin/wallet/ledger', { params })
return data.data.items
}
+17
View File
@@ -7,6 +7,8 @@ export interface Order {
account_id: number account_id: number
owner_id: number owner_id: number
renter_id: number renter_id: number
owner_phone?: string
renter_phone?: string
title: string title: string
server_region: string server_region: string
login_platform: string login_platform: string
@@ -55,11 +57,21 @@ export async function fetchOrders() {
return data.data.items return data.data.items
} }
export async function fetchAdminOrders() {
const { data } = await apiClient.get<ApiResponse<{ items: Order[] }>>('/admin/orders')
return data.data.items
}
export async function fetchOrder(id: string | number) { export async function fetchOrder(id: string | number) {
const { data } = await apiClient.get<ApiResponse<Order>>(`/orders/${id}`) const { data } = await apiClient.get<ApiResponse<Order>>(`/orders/${id}`)
return data.data return data.data
} }
export async function fetchAdminOrder(id: string | number) {
const { data } = await apiClient.get<ApiResponse<Order>>(`/admin/orders/${id}`)
return data.data
}
export async function cancelOrder(id: number) { export async function cancelOrder(id: number) {
const { data } = await apiClient.post<ApiResponse<{ cancelled: boolean }>>(`/orders/${id}/cancel`) const { data } = await apiClient.post<ApiResponse<{ cancelled: boolean }>>(`/orders/${id}/cancel`)
return data.data return data.data
@@ -75,6 +87,11 @@ export async function fetchHandoffRecords(id: string | number) {
return data.data.items return data.data.items
} }
export async function fetchAdminHandoffRecords(id: string | number) {
const { data } = await apiClient.get<ApiResponse<{ items: HandoffRecord[] }>>(`/admin/orders/${id}/handoff-records`)
return data.data.items
}
export async function confirmReceive(id: number) { export async function confirmReceive(id: number) {
const { data } = await apiClient.post<ApiResponse<{ received: boolean }>>(`/orders/${id}/confirm-receive`) const { data } = await apiClient.post<ApiResponse<{ received: boolean }>>(`/orders/${id}/confirm-receive`)
return data.data return data.data
+4 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { DataLine, DocumentChecked, Operation, ScaleToOriginal, SwitchButton } from '@element-plus/icons-vue' import { DataLine, DocumentChecked, Operation, ScaleToOriginal, SwitchButton, Tickets, User, Wallet } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { computed } from 'vue' import { computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -12,8 +12,11 @@ const adminSession = useAdminSessionStore()
const navItems = [ const navItems = [
{ label: '仪表盘', to: '/admin/dashboard', icon: DataLine }, { label: '仪表盘', to: '/admin/dashboard', icon: DataLine },
{ label: '用户管理', to: '/admin/users', icon: User },
{ label: '订单管理', to: '/admin/orders', icon: Tickets },
{ label: '商品审核', to: '/admin/listings/review', icon: DocumentChecked }, { label: '商品审核', to: '/admin/listings/review', icon: DocumentChecked },
{ label: '仲裁中心', to: '/admin/disputes', icon: ScaleToOriginal }, { label: '仲裁中心', to: '/admin/disputes', icon: ScaleToOriginal },
{ label: '资金流水', to: '/admin/wallet-ledger', icon: Wallet },
{ label: '系统配置', to: '/admin/system-configs', icon: Operation }, { label: '系统配置', to: '/admin/system-configs', icon: Operation },
] ]
+24
View File
@@ -25,6 +25,24 @@ const router = createRouter({
component: () => import('@/views/admin/AdminDashboardView.vue'), component: () => import('@/views/admin/AdminDashboardView.vue'),
meta: { layout: 'admin', requiresAdmin: true }, meta: { layout: 'admin', requiresAdmin: true },
}, },
{
path: '/admin/users',
name: 'admin-users',
component: () => import('@/views/admin/AdminUsersView.vue'),
meta: { layout: 'admin', requiresAdmin: true },
},
{
path: '/admin/orders',
name: 'admin-orders',
component: () => import('@/views/admin/AdminOrdersView.vue'),
meta: { layout: 'admin', requiresAdmin: true },
},
{
path: '/admin/orders/:id',
name: 'admin-order-detail',
component: () => import('@/views/admin/AdminOrderDetailView.vue'),
meta: { layout: 'admin', requiresAdmin: true },
},
{ {
path: '/admin/listings/review', path: '/admin/listings/review',
name: 'admin-listing-review', name: 'admin-listing-review',
@@ -37,6 +55,12 @@ const router = createRouter({
component: () => import('@/views/admin/AdminDisputesView.vue'), component: () => import('@/views/admin/AdminDisputesView.vue'),
meta: { layout: 'admin', requiresAdmin: true }, meta: { layout: 'admin', requiresAdmin: true },
}, },
{
path: '/admin/wallet-ledger',
name: 'admin-wallet-ledger',
component: () => import('@/views/admin/AdminWalletLedgerView.vue'),
meta: { layout: 'admin', requiresAdmin: true },
},
{ {
path: '/admin/system-configs', path: '/admin/system-configs',
name: 'admin-system-configs', name: 'admin-system-configs',
+154 -31
View File
@@ -91,6 +91,7 @@ a {
.admin-sidebar { .admin-sidebar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0;
border-right: 1px solid #dfe5ec; border-right: 1px solid #dfe5ec;
background: #111827; background: #111827;
padding: 20px 16px; padding: 20px 16px;
@@ -130,6 +131,7 @@ a {
.admin-workspace { .admin-workspace {
min-width: 0; min-width: 0;
overflow: hidden;
} }
.admin-topbar { .admin-topbar {
@@ -155,8 +157,13 @@ a {
} }
.admin-main { .admin-main {
width: 100%;
min-width: 0; min-width: 0;
padding: 28px; padding: 28px clamp(18px, 2.4vw, 36px);
}
.admin-main .page {
max-width: none;
} }
.admin-login-shell { .admin-login-shell {
@@ -231,7 +238,8 @@ a {
} }
.page { .page {
max-width: 1120px; width: 100%;
max-width: 1440px;
} }
.page-header { .page-header {
@@ -260,7 +268,7 @@ h1 {
.metric-grid { .metric-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px; gap: 16px;
margin-top: 28px; margin-top: 28px;
} }
@@ -285,12 +293,12 @@ h1 {
} }
.dashboard-metrics { .dashboard-metrics {
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
} }
.dashboard-panels { .dashboard-panels {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px; gap: 16px;
} }
@@ -433,6 +441,15 @@ h1 {
margin-top: 28px; margin-top: 28px;
} }
.table-panel {
width: 100%;
overflow-x: auto;
}
.table-panel .el-table__inner-wrapper {
min-width: 760px;
}
.listing-form { .listing-form {
max-width: 860px; max-width: 860px;
border: 1px solid #e4e7ed; border: 1px solid #e4e7ed;
@@ -448,7 +465,7 @@ h1 {
} }
.order-panel { .order-panel {
max-width: 520px; max-width: min(100%, 560px);
margin-top: 28px; margin-top: 28px;
border: 1px solid #e4e7ed; border: 1px solid #e4e7ed;
border-radius: 8px; border-radius: 8px;
@@ -503,6 +520,50 @@ h1 {
line-height: 1.6; line-height: 1.6;
} }
.toolbar-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
justify-content: flex-end;
}
.filter-panel {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px 16px;
margin-top: 24px;
border: 1px solid #e4e7ed;
border-radius: 8px;
background: #ffffff;
padding: 16px;
}
.filter-panel .el-form-item {
margin-bottom: 0;
}
.table-subtext {
display: block;
margin-top: 4px;
color: #6b7785;
font-size: 12px;
}
.code-panel {
max-width: none;
}
.code-panel pre {
overflow-x: auto;
margin: 0;
border-radius: 6px;
background: #111827;
color: #dbe4ee;
padding: 14px;
line-height: 1.6;
}
.notification-list { .notification-list {
display: grid; display: grid;
gap: 12px; gap: 12px;
@@ -551,12 +612,64 @@ h1 {
justify-content: flex-end; justify-content: flex-end;
} }
@media (max-width: 760px) { @media (max-width: 1180px) {
.app-shell { .admin-shell {
grid-template-columns: 220px 1fr;
}
.admin-sidebar {
padding: 18px 12px;
}
.admin-nav-link {
padding: 0 10px;
}
}
@media (max-width: 900px) {
.admin-shell {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.admin-shell { .admin-sidebar {
position: sticky;
top: 0;
z-index: 10;
border-right: 0;
border-bottom: 1px solid #1f2937;
padding: 12px 14px;
}
.admin-brand {
min-height: 36px;
}
.admin-nav {
grid-auto-flow: column;
grid-auto-columns: max-content;
gap: 8px;
margin-top: 12px;
overflow-x: auto;
padding-bottom: 2px;
}
.admin-nav-link {
min-height: 38px;
white-space: nowrap;
}
.admin-topbar {
min-height: 58px;
padding: 0 16px;
}
.admin-main {
padding: 22px 16px;
}
}
@media (max-width: 760px) {
.app-shell {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -574,32 +687,10 @@ h1 {
overflow-x: auto; overflow-x: auto;
} }
.admin-sidebar {
position: sticky;
top: 0;
z-index: 10;
border-right: 0;
border-bottom: 1px solid #1f2937;
}
.admin-nav {
grid-auto-flow: column;
grid-auto-columns: max-content;
overflow-x: auto;
}
.admin-topbar {
padding: 0 16px;
}
.main { .main {
padding: 24px 16px; padding: 24px 16px;
} }
.admin-main {
padding: 24px 16px;
}
.metric-grid { .metric-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -615,4 +706,36 @@ h1 {
.page-header-row { .page-header-row {
display: grid; display: grid;
} }
.toolbar-actions {
justify-content: flex-start;
}
}
@media (max-width: 520px) {
.admin-topbar {
align-items: flex-start;
flex-direction: column;
justify-content: center;
gap: 10px;
padding: 12px 16px;
}
.admin-topbar .el-button {
width: 100%;
}
.dashboard-panels {
grid-template-columns: 1fr;
}
.admin-captcha-row,
.code-row {
grid-template-columns: 1fr;
}
.captcha-image-button,
.captcha-image-button img {
width: 100%;
}
} }
@@ -91,6 +91,14 @@ function money(value?: number) {
<div class="order-panel dashboard-panel"> <div class="order-panel dashboard-panel">
<h2>后台入口</h2> <h2>后台入口</h2>
<RouterLink class="pending-row" to="/admin/users">
<span>用户管理</span>
<strong>进入</strong>
</RouterLink>
<RouterLink class="pending-row" to="/admin/orders">
<span>订单管理</span>
<strong>进入</strong>
</RouterLink>
<RouterLink class="pending-row" to="/admin/disputes"> <RouterLink class="pending-row" to="/admin/disputes">
<span>仲裁中心</span> <span>仲裁中心</span>
<strong>进入</strong> <strong>进入</strong>
@@ -85,7 +85,7 @@ function readError(error: unknown, fallback: string) {
<el-table-column prop="price_hourly" label="时租" width="90" /> <el-table-column prop="price_hourly" label="时租" width="90" />
<el-table-column prop="deposit_amount" label="押金" width="90" /> <el-table-column prop="deposit_amount" label="押金" width="90" />
<el-table-column prop="updated_at" label="提交时间" min-width="180" /> <el-table-column prop="updated_at" label="提交时间" min-width="180" />
<el-table-column label="操作" width="170" fixed="right"> <el-table-column label="操作" width="170">
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" type="primary" :loading="submitting" @click="handleApprove(row)">通过</el-button> <el-button size="small" type="primary" :loading="submitting" @click="handleApprove(row)">通过</el-button>
<el-button size="small" type="danger" @click="openReject(row)">拒绝</el-button> <el-button size="small" type="danger" @click="openReject(row)">拒绝</el-button>
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { fetchAdminHandoffRecords, fetchAdminOrder, type HandoffRecord, type Order } from '@/api/orders'
const route = useRoute()
const loading = ref(false)
const order = ref<Order | null>(null)
const handoffRecords = ref<HandoffRecord[]>([])
const snapshotText = computed(() => JSON.stringify(order.value?.account_snapshot || {}, null, 2))
onMounted(loadOrder)
async function loadOrder() {
loading.value = true
try {
order.value = await fetchAdminOrder(String(route.params.id))
handoffRecords.value = await fetchAdminHandoffRecords(String(route.params.id))
} finally {
loading.value = false
}
}
</script>
<template>
<section class="page" v-loading="loading">
<div v-if="order" class="page-header-row">
<div class="page-header">
<p class="eyebrow">{{ order.order_no }}</p>
<h1>订单详情</h1>
<p>{{ order.title }} · {{ order.server_region }} / {{ order.login_platform }}</p>
</div>
<RouterLink to="/admin/orders">
<el-button>返回列表</el-button>
</RouterLink>
</div>
<div v-if="order" class="metric-grid dashboard-metrics">
<div class="metric-card">
<span>订单状态</span>
<strong>{{ order.status }}</strong>
</div>
<div class="metric-card">
<span>交接状态</span>
<strong>{{ order.handoff_status }}</strong>
</div>
<div class="metric-card">
<span>租金</span>
<strong>¥{{ order.rent_amount }}</strong>
</div>
<div class="metric-card">
<span>押金</span>
<strong>¥{{ order.deposit_amount }}</strong>
</div>
</div>
<div v-if="order" class="dashboard-panels">
<div class="order-panel dashboard-panel">
<h2>用户信息</h2>
<p>租客{{ order.renter_phone || order.renter_id }}</p>
<p>号主{{ order.owner_phone || order.owner_id }}</p>
<p>租期{{ order.rent_hours }} 小时</p>
<p>开始{{ order.rent_start_at || '未开始' }}</p>
<p>结束{{ order.rent_end_at || '未设置' }}</p>
</div>
<div class="order-panel dashboard-panel">
<h2>交接记录</h2>
<el-empty v-if="handoffRecords.length === 0" description="暂无交接记录" />
<div v-for="record in handoffRecords" :key="record.id" class="timeline-item">
<strong>{{ record.type }}</strong>
<p>{{ record.content }}</p>
<span>{{ record.created_at }}</span>
</div>
</div>
</div>
<div v-if="order" class="order-panel dashboard-panel code-panel">
<h2>账号快照</h2>
<pre>{{ snapshotText }}</pre>
</div>
</section>
</template>
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { fetchAdminOrders, type Order } from '@/api/orders'
const loading = ref(false)
const orders = ref<Order[]>([])
const status = ref('')
const filteredOrders = computed(() => {
if (!status.value) return orders.value
return orders.value.filter((item) => item.status === status.value)
})
onMounted(loadOrders)
async function loadOrders() {
loading.value = true
try {
orders.value = await fetchAdminOrders()
} finally {
loading.value = false
}
}
</script>
<template>
<section class="page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Orders</p>
<h1>订单管理</h1>
<p>查看全量订单交接状态金额和结算状态</p>
</div>
<div class="toolbar-actions">
<el-select v-model="status" clearable placeholder="订单状态" style="width: 180px">
<el-option label="待交接" value="pending_handoff" />
<el-option label="租赁中" value="renting" />
<el-option label="待归还确认" value="pending_return_confirm" />
<el-option label="申诉中" value="disputing" />
<el-option label="已完成" value="completed" />
<el-option label="已关闭" value="closed" />
<el-option label="已取消" value="cancelled" />
</el-select>
<el-button @click="loadOrders">刷新</el-button>
</div>
</div>
<el-table v-loading="loading" class="table-panel" :data="filteredOrders">
<el-table-column prop="order_no" label="订单号" min-width="230" />
<el-table-column prop="title" label="账号" min-width="170" />
<el-table-column prop="renter_phone" label="租客" min-width="130" />
<el-table-column prop="owner_phone" label="号主" min-width="130" />
<el-table-column prop="status" label="状态" width="140" />
<el-table-column prop="handoff_status" label="交接" width="180" />
<el-table-column prop="settlement_status" label="结算" width="120" />
<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-column label="操作" width="100">
<template #default="{ row }">
<RouterLink :to="`/admin/orders/${row.id}`">
<el-button size="small">详情</el-button>
</RouterLink>
</template>
</el-table-column>
</el-table>
</section>
</template>
+110
View File
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { onMounted, ref } from 'vue'
import { fetchAdminUsers, freezeAdminUser, unfreezeAdminUser, type AdminUserItem } from '@/api/adminUsers'
const loading = ref(false)
const submitting = ref(false)
const users = ref<AdminUserItem[]>([])
const activeUser = ref<AdminUserItem | null>(null)
const freezeReason = ref('')
onMounted(loadUsers)
async function loadUsers() {
loading.value = true
try {
users.value = await fetchAdminUsers()
} finally {
loading.value = false
}
}
function openFreeze(row: AdminUserItem) {
activeUser.value = row
freezeReason.value = ''
}
async function handleFreeze() {
if (!activeUser.value) return
submitting.value = true
try {
await freezeAdminUser(activeUser.value.id, freezeReason.value)
ElMessage.success('用户已冻结')
activeUser.value = null
await loadUsers()
} catch (error) {
ElMessage.error(readError(error, '冻结失败'))
} finally {
submitting.value = false
}
}
async function handleUnfreeze(row: AdminUserItem) {
submitting.value = true
try {
await unfreezeAdminUser(row.id)
ElMessage.success('用户已解冻')
await loadUsers()
} catch (error) {
ElMessage.error(readError(error, '解冻失败'))
} finally {
submitting.value = false
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
</script>
<template>
<section class="page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Users</p>
<h1>用户管理</h1>
<p>查看用户实名风险信用和业务数量处理冻结与解冻</p>
</div>
<el-button @click="loadUsers">刷新</el-button>
</div>
<el-table v-loading="loading" class="table-panel" :data="users">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="phone" label="手机号" min-width="130" />
<el-table-column prop="nickname" label="昵称" min-width="130" />
<el-table-column prop="realname_status" label="实名" width="110" />
<el-table-column prop="risk_status" label="风险" width="110" />
<el-table-column prop="credit_score" label="信用分" width="100" />
<el-table-column prop="status" label="状态" width="110" />
<el-table-column prop="order_count" label="订单" width="90" />
<el-table-column prop="listing_count" label="发布" width="90" />
<el-table-column prop="dispute_count" label="申诉" width="90" />
<el-table-column prop="last_login_at" label="最近登录" min-width="180" />
<el-table-column label="操作" width="150">
<template #default="{ row }">
<el-button v-if="row.status === 'active'" size="small" type="danger" :loading="submitting" @click="openFreeze(row)">
冻结
</el-button>
<el-button v-else size="small" type="primary" :loading="submitting" @click="handleUnfreeze(row)">解冻</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog :model-value="!!activeUser" title="冻结用户" width="560px" @update:model-value="activeUser = null">
<div v-if="activeUser" class="dialog-body">
<p><strong>{{ activeUser.phone }}</strong> · {{ activeUser.nickname }}</p>
<el-input v-model="freezeReason" type="textarea" :rows="4" placeholder="填写冻结原因,便于审计追踪" />
</div>
<template #footer>
<el-button @click="activeUser = null">取消</el-button>
<el-button type="danger" :loading="submitting" @click="handleFreeze">确认冻结</el-button>
</template>
</el-dialog>
</section>
</template>
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { Search } from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { fetchAdminWalletLedger, type AdminWalletLedger } from '@/api/adminWallet'
const loading = ref(false)
const ledger = ref<AdminWalletLedger[]>([])
const filters = reactive({
user_id: '',
order_id: '',
biz_type: '',
limit: 200,
})
const inAmount = computed(() =>
ledger.value.filter((item) => item.direction === 'in').reduce((sum, item) => sum + Number(item.amount || 0), 0),
)
const outAmount = computed(() =>
ledger.value.filter((item) => item.direction === 'out').reduce((sum, item) => sum + Number(item.amount || 0), 0),
)
const frozenAmount = computed(() =>
ledger.value.filter((item) => item.balance_type === 'frozen').reduce((sum, item) => sum + Number(item.amount || 0), 0),
)
onMounted(loadLedger)
async function loadLedger() {
loading.value = true
try {
ledger.value = await fetchAdminWalletLedger(filters)
} finally {
loading.value = false
}
}
function resetFilters() {
filters.user_id = ''
filters.order_id = ''
filters.biz_type = ''
filters.limit = 200
void loadLedger()
}
function money(value: number) {
return `¥${value.toFixed(2)}`
}
function directionType(direction: string) {
return direction === 'in' ? 'success' : 'danger'
}
function directionLabel(direction: string) {
return direction === 'in' ? '入账' : '出账'
}
</script>
<template>
<section class="page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Wallet Ledger</p>
<h1>资金流水</h1>
<p>查看租金押金冻结解冻退款和结算流水当前为开发态账务记录不代表真实支付余额</p>
</div>
<div class="toolbar-actions">
<el-button @click="resetFilters">重置</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="loadLedger">查询</el-button>
</div>
</div>
<div class="metric-grid">
<div class="metric-card">
<span>当前结果</span>
<strong>{{ ledger.length }} </strong>
</div>
<div class="metric-card">
<span>入账合计</span>
<strong>{{ money(inAmount) }}</strong>
</div>
<div class="metric-card">
<span>出账合计</span>
<strong>{{ money(outAmount) }}</strong>
</div>
<div class="metric-card">
<span>冻结相关金额</span>
<strong>{{ money(frozenAmount) }}</strong>
</div>
</div>
<el-form class="filter-panel" label-position="top">
<el-form-item label="用户 ID">
<el-input v-model="filters.user_id" clearable placeholder="按用户筛选" />
</el-form-item>
<el-form-item label="订单 ID">
<el-input v-model="filters.order_id" clearable placeholder="按订单筛选" />
</el-form-item>
<el-form-item label="业务类型">
<el-select v-model="filters.biz_type" clearable placeholder="全部业务" class="full-control">
<el-option label="订单冻结" value="order_freeze" />
<el-option label="订单取消释放" value="order_cancel_release" />
<el-option label="订单结算" value="order_settlement" />
<el-option label="押金退回" value="deposit_refund" />
</el-select>
</el-form-item>
<el-form-item label="查询条数">
<el-input-number v-model="filters.limit" :min="20" :max="500" :step="20" class="full-control" />
</el-form-item>
</el-form>
<el-table v-loading="loading" class="table-panel" :data="ledger">
<el-table-column prop="ledger_no" label="流水号" min-width="230" />
<el-table-column label="用户" min-width="160">
<template #default="{ row }">
<strong>{{ row.user_phone || `用户 ${row.user_id}` }}</strong>
<span class="table-subtext">ID: {{ row.user_id }}</span>
</template>
</el-table-column>
<el-table-column label="订单" min-width="180">
<template #default="{ row }">
<RouterLink v-if="row.order_id" :to="`/admin/orders/${row.order_id}`">{{ row.order_no || row.order_id }}</RouterLink>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="biz_type" label="业务类型" width="150" />
<el-table-column label="方向" width="90">
<template #default="{ row }">
<el-tag :type="directionType(row.direction)">{{ directionLabel(row.direction) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="金额" width="110">
<template #default="{ row }">{{ money(Number(row.amount)) }}</template>
</el-table-column>
<el-table-column prop="balance_type" label="余额类型" width="110" />
<el-table-column label="变化后余额" width="130">
<template #default="{ row }">{{ money(Number(row.balance_after)) }}</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="220" />
<el-table-column prop="created_at" label="创建时间" min-width="180" />
</el-table>
</section>
</template>