后台“资金流水
This commit is contained in:
@@ -34,11 +34,14 @@ npm run dev
|
||||
- 账号交接已支持号主提交说明、租客确认收号,确认后订单进入租赁中。
|
||||
- 归还流程已支持租客提交归还、号主确认归还,完成后账号重新上架。
|
||||
- 钱包账务当前为开发态模拟流水,可通过 `GET /api/wallet/balance` 和 `GET /api/wallet/ledger` 查看。
|
||||
- 后台资金流水已接入,页面为 `http://localhost:5173/admin/wallet-ledger`,支持按用户、订单和业务类型查询。
|
||||
- 站内信已支持订单关键节点自动写入,可通过 `GET /api/notifications` 查看。
|
||||
- 申诉仲裁已支持订单双方发起申诉、开发态后台处理,后台页面为 `http://localhost:5173/admin/disputes`。
|
||||
- 系统配置已支持默认配置初始化和后台编辑,页面为 `http://localhost:5173/admin/system-configs`,更新会写入审计日志。
|
||||
- 后台已使用独立登录、图形验证码和独立管理 UI,页面为 `http://localhost:5173/admin/login`;开发态默认管理员为 `admin / admin123456`。
|
||||
- 后台仪表盘已接入真实统计数据,页面为 `http://localhost:5173/admin/dashboard`。
|
||||
- 用户管理后台已接入,页面为 `http://localhost:5173/admin/users`,支持冻结和解冻用户。
|
||||
- 订单管理后台已接入,页面为 `http://localhost:5173/admin/orders`,支持查看全量订单和交接记录。
|
||||
- 商品审核后台已接入,页面为 `http://localhost:5173/admin/listings/review`。
|
||||
|
||||
## 文档
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -13,6 +13,8 @@ type OrderDTO struct {
|
||||
AccountID uint64 `json:"account_id"`
|
||||
OwnerID uint64 `json:"owner_id"`
|
||||
RenterID uint64 `json:"renter_id"`
|
||||
OwnerPhone string `json:"owner_phone,omitempty"`
|
||||
RenterPhone string `json:"renter_phone,omitempty"`
|
||||
Title string `json:"title"`
|
||||
ServerRegion string `json:"server_region"`
|
||||
LoginPlatform string `json:"login_platform"`
|
||||
|
||||
@@ -52,6 +52,41 @@ func (h *Handler) List(c *gin.Context) {
|
||||
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) {
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
|
||||
@@ -462,6 +462,47 @@ func (r *Repository) ListForUser(userID uint64) ([]OrderDTO, error) {
|
||||
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) {
|
||||
var row orderRow
|
||||
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")
|
||||
}
|
||||
|
||||
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 {
|
||||
model.RentalOrder
|
||||
Title string
|
||||
ServerRegion string
|
||||
LoginPlatform string
|
||||
OwnerPhone string
|
||||
RenterPhone string
|
||||
}
|
||||
|
||||
func (row orderRow) toDTO() OrderDTO {
|
||||
@@ -503,6 +554,8 @@ func (row orderRow) toDTO() OrderDTO {
|
||||
AccountID: row.AccountID,
|
||||
OwnerID: row.OwnerID,
|
||||
RenterID: row.RenterID,
|
||||
OwnerPhone: row.OwnerPhone,
|
||||
RenterPhone: row.RenterPhone,
|
||||
Title: row.Title,
|
||||
ServerRegion: row.ServerRegion,
|
||||
LoginPlatform: row.LoginPlatform,
|
||||
|
||||
@@ -88,6 +88,27 @@ func (s *Service) ListForUser(userID uint64) ([]OrderDTO, error) {
|
||||
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) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
|
||||
@@ -23,3 +23,28 @@ type LedgerDTO struct {
|
||||
Remark string `json:"remark"`
|
||||
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 (
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"hfb_sys/backend/internal/middleware"
|
||||
"hfb_sys/backend/pkg/response"
|
||||
@@ -45,6 +46,49 @@ func (h *Handler) Ledger(c *gin.Context) {
|
||||
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) {
|
||||
value, ok := c.Get(middleware.ContextUserID)
|
||||
if !ok {
|
||||
|
||||
@@ -57,6 +57,37 @@ func (r *Repository) Ledger(userID uint64) ([]LedgerDTO, error) {
|
||||
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 {
|
||||
for _, entry := range entries {
|
||||
if entry.Amount <= 0 {
|
||||
|
||||
@@ -25,3 +25,10 @@ func (s *Service) Ledger(userID uint64) ([]LedgerDTO, error) {
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"hfb_sys/backend/internal/middleware"
|
||||
"hfb_sys/backend/internal/modules/adminauth"
|
||||
"hfb_sys/backend/internal/modules/admindashboard"
|
||||
"hfb_sys/backend/internal/modules/adminuser"
|
||||
"hfb_sys/backend/internal/modules/auth"
|
||||
"hfb_sys/backend/internal/modules/dispute"
|
||||
"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)
|
||||
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)
|
||||
var realnameRepo *realname.Repository
|
||||
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.POST("/auth/logout", adminAuthHandler.Logout)
|
||||
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.POST("/listings/:id/approve", listingHandler.Approve)
|
||||
adminRoutes.POST("/listings/:id/reject", listingHandler.Reject)
|
||||
adminRoutes.GET("/disputes", disputeHandler.AdminList)
|
||||
adminRoutes.POST("/disputes/:id/arbitrate", disputeHandler.AdminArbitrate)
|
||||
adminRoutes.GET("/wallet/ledger", walletHandler.AdminLedger)
|
||||
adminRoutes.GET("/system-configs", systemConfigHandler.List)
|
||||
adminRoutes.PUT("/system-configs/:key", systemConfigHandler.Update)
|
||||
}
|
||||
|
||||
+8
-1
@@ -51,12 +51,19 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。
|
||||
- `POST /api/admin/auth/logout`
|
||||
- `GET /api/admin/me`
|
||||
- `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`
|
||||
- `POST /api/admin/listings/{id}/approve`
|
||||
- `POST /api/admin/listings/{id}/reject`
|
||||
- `GET /api/admin/disputes`
|
||||
- `POST /api/admin/disputes/{id}/arbitrate`
|
||||
- `GET /api/admin/wallet/ledger`
|
||||
- `GET /api/admin/system-configs`
|
||||
- `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 权限后再按角色收紧访问控制。
|
||||
|
||||
@@ -64,6 +64,8 @@
|
||||
- 取消待交接订单时释放租客冻结金额。
|
||||
- 订单完成时释放租客冻结金额,给号主生成租金入账流水,并给租客生成押金退回流水。
|
||||
- 每笔流水记录 `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` 展示订单状态、双方用户、租期、交接记录和账号资产快照。
|
||||
- 当前后台订单管理先做只读能力,后续再补后台关闭订单、标记异常和客服介入操作。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -7,6 +7,8 @@ export interface Order {
|
||||
account_id: number
|
||||
owner_id: number
|
||||
renter_id: number
|
||||
owner_phone?: string
|
||||
renter_phone?: string
|
||||
title: string
|
||||
server_region: string
|
||||
login_platform: string
|
||||
@@ -55,11 +57,21 @@ export async function fetchOrders() {
|
||||
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) {
|
||||
const { data } = await apiClient.get<ApiResponse<Order>>(`/orders/${id}`)
|
||||
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) {
|
||||
const { data } = await apiClient.post<ApiResponse<{ cancelled: boolean }>>(`/orders/${id}/cancel`)
|
||||
return data.data
|
||||
@@ -75,6 +87,11 @@ export async function fetchHandoffRecords(id: string | number) {
|
||||
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) {
|
||||
const { data } = await apiClient.post<ApiResponse<{ received: boolean }>>(`/orders/${id}/confirm-receive`)
|
||||
return data.data
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -12,8 +12,11 @@ const adminSession = useAdminSessionStore()
|
||||
|
||||
const navItems = [
|
||||
{ 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/disputes', icon: ScaleToOriginal },
|
||||
{ label: '资金流水', to: '/admin/wallet-ledger', icon: Wallet },
|
||||
{ label: '系统配置', to: '/admin/system-configs', icon: Operation },
|
||||
]
|
||||
|
||||
|
||||
@@ -25,6 +25,24 @@ const router = createRouter({
|
||||
component: () => import('@/views/admin/AdminDashboardView.vue'),
|
||||
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',
|
||||
name: 'admin-listing-review',
|
||||
@@ -37,6 +55,12 @@ const router = createRouter({
|
||||
component: () => import('@/views/admin/AdminDisputesView.vue'),
|
||||
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',
|
||||
name: 'admin-system-configs',
|
||||
|
||||
+154
-31
@@ -91,6 +91,7 @@ a {
|
||||
.admin-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
border-right: 1px solid #dfe5ec;
|
||||
background: #111827;
|
||||
padding: 20px 16px;
|
||||
@@ -130,6 +131,7 @@ a {
|
||||
|
||||
.admin-workspace {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-topbar {
|
||||
@@ -155,8 +157,13 @@ a {
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 28px;
|
||||
padding: 28px clamp(18px, 2.4vw, 36px);
|
||||
}
|
||||
|
||||
.admin-main .page {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.admin-login-shell {
|
||||
@@ -231,7 +238,8 @@ a {
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1120px;
|
||||
width: 100%;
|
||||
max-width: 1440px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
@@ -260,7 +268,7 @@ h1 {
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
@@ -285,12 +293,12 @@ h1 {
|
||||
}
|
||||
|
||||
.dashboard-metrics {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||
}
|
||||
|
||||
.dashboard-panels {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@@ -433,6 +441,15 @@ h1 {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.table-panel {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table-panel .el-table__inner-wrapper {
|
||||
min-width: 760px;
|
||||
}
|
||||
|
||||
.listing-form {
|
||||
max-width: 860px;
|
||||
border: 1px solid #e4e7ed;
|
||||
@@ -448,7 +465,7 @@ h1 {
|
||||
}
|
||||
|
||||
.order-panel {
|
||||
max-width: 520px;
|
||||
max-width: min(100%, 560px);
|
||||
margin-top: 28px;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
@@ -503,6 +520,50 @@ h1 {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@@ -551,12 +612,64 @@ h1 {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.app-shell {
|
||||
@media (max-width: 1180px) {
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -574,32 +687,10 @@ h1 {
|
||||
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 {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -615,4 +706,36 @@ h1 {
|
||||
.page-header-row {
|
||||
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">
|
||||
<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">
|
||||
<span>仲裁中心</span>
|
||||
<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="deposit_amount" label="押金" width="90" />
|
||||
<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 }">
|
||||
<el-button size="small" type="primary" :loading="submitting" @click="handleApprove(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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user