Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b156b5abf | ||
|
|
cdee93c7c5 | ||
|
|
3631e70321 | ||
|
|
25e5599e0a |
@@ -253,16 +253,16 @@ func (j *Job) handleReturnOverdue(ctx context.Context, now time.Time, cfg thresh
|
|||||||
notification.Entry{
|
notification.Entry{
|
||||||
UserID: order.RenterID,
|
UserID: order.RenterID,
|
||||||
Type: "timeout",
|
Type: "timeout",
|
||||||
Title: "订单已逾期未归还",
|
Title: "订单已逾期未结账",
|
||||||
Content: "订单已超过预计截止时间,请尽快提交归还说明,避免进入申诉处理。",
|
Content: "订单已超过预计截止时间,请尽快发起结账,避免进入申诉处理。",
|
||||||
BizType: "order",
|
BizType: "order",
|
||||||
BizID: &orderID,
|
BizID: &orderID,
|
||||||
},
|
},
|
||||||
notification.Entry{
|
notification.Entry{
|
||||||
UserID: order.OwnerID,
|
UserID: order.OwnerID,
|
||||||
Type: "timeout",
|
Type: "timeout",
|
||||||
Title: "租客逾期未归还",
|
Title: "租客逾期未结账",
|
||||||
Content: "租客未在预计截止后及时归还,你可以发起申诉或等待客服处理。",
|
Content: "租客未在预计截止后及时发起结账,你可以发起申诉或等待客服处理。",
|
||||||
BizType: "order",
|
BizType: "order",
|
||||||
BizID: &orderID,
|
BizID: &orderID,
|
||||||
},
|
},
|
||||||
@@ -281,7 +281,7 @@ func (j *Job) handleReturnOverdue(ctx context.Context, now time.Time, cfg thresh
|
|||||||
func (j *Job) handleOwnerReturnConfirmTimeout(ctx context.Context, now time.Time, cfg thresholds) (int, error) {
|
func (j *Job) handleOwnerReturnConfirmTimeout(ctx context.Context, now time.Time, cfg thresholds) (int, error) {
|
||||||
var rows []model.RentalOrder
|
var rows []model.RentalOrder
|
||||||
err := j.db.WithContext(ctx).
|
err := j.db.WithContext(ctx).
|
||||||
Where("status = ? AND handoff_status = ? AND updated_at <= ?", "pending_return_confirm", "pending_owner_return_confirm", now.Add(-time.Duration(cfg.OwnerReturnConfirmTimeoutMinutes)*time.Minute)).
|
Where("status = ? AND handoff_status = ? AND updated_at <= ?", "pending_checkout_confirm", "pending_owner_checkout", now.Add(-time.Duration(cfg.OwnerReturnConfirmTimeoutMinutes)*time.Minute)).
|
||||||
Order("id ASC").
|
Order("id ASC").
|
||||||
Limit(100).
|
Limit(100).
|
||||||
Find(&rows).Error
|
Find(&rows).Error
|
||||||
@@ -290,28 +290,28 @@ func (j *Job) handleOwnerReturnConfirmTimeout(ctx context.Context, now time.Time
|
|||||||
}
|
}
|
||||||
count := 0
|
count := 0
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
if err := j.updateOrder(ctx, row.ID, "order.timeout.owner_return_confirm", func(tx *gorm.DB, order *model.RentalOrder) (string, error) {
|
if err := j.updateOrder(ctx, row.ID, "order.timeout.owner_checkout_confirm", func(tx *gorm.DB, order *model.RentalOrder) (string, error) {
|
||||||
if order.Status != "pending_return_confirm" || order.HandoffStatus != "pending_owner_return_confirm" {
|
if order.Status != "pending_checkout_confirm" || order.HandoffStatus != "pending_owner_checkout" {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
before := snapshot(order)
|
before := snapshot(order)
|
||||||
order.Status = "abnormal"
|
order.Status = "abnormal"
|
||||||
order.HandoffStatus = "owner_return_confirm_timeout"
|
order.HandoffStatus = "owner_checkout_confirm_timeout"
|
||||||
orderID := order.ID
|
orderID := order.ID
|
||||||
if err := notification.Append(tx,
|
if err := notification.Append(tx,
|
||||||
notification.Entry{
|
notification.Entry{
|
||||||
UserID: order.RenterID,
|
UserID: order.RenterID,
|
||||||
Type: "timeout",
|
Type: "timeout",
|
||||||
Title: "号主确认归还超时",
|
Title: "号主确认结账超时",
|
||||||
Content: "号主未在规定时间内确认归还,订单已进入客服复核状态。",
|
Content: "号主未在规定时间内确认结账,订单已进入客服复核状态。",
|
||||||
BizType: "order",
|
BizType: "order",
|
||||||
BizID: &orderID,
|
BizID: &orderID,
|
||||||
},
|
},
|
||||||
notification.Entry{
|
notification.Entry{
|
||||||
UserID: order.OwnerID,
|
UserID: order.OwnerID,
|
||||||
Type: "timeout",
|
Type: "timeout",
|
||||||
Title: "确认归还已超时",
|
Title: "确认结账已超时",
|
||||||
Content: "你未在规定时间内确认归还,订单已进入客服复核状态。",
|
Content: "你未在规定时间内确认结账,订单已进入客服复核状态。",
|
||||||
BizType: "order",
|
BizType: "order",
|
||||||
BizID: &orderID,
|
BizID: &orderID,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OrderCheckout struct {
|
||||||
|
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||||
|
OrderID uint64 `gorm:"not null;index" json:"order_id"`
|
||||||
|
InitiatedBy uint64 `gorm:"not null" json:"initiated_by"`
|
||||||
|
Status string `gorm:"size:32;not null;default:'submitted'" json:"status"`
|
||||||
|
RentAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"rent_amount"`
|
||||||
|
DepositAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"deposit_amount"`
|
||||||
|
ConsumableAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"consumable_amount"`
|
||||||
|
CoinConsumedM float64 `gorm:"type:decimal(12,2);not null;default:0" json:"coin_consumed_m"`
|
||||||
|
OtherAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"other_amount"`
|
||||||
|
DepositDeductAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"deposit_deduct_amount"`
|
||||||
|
RenterRefundAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"renter_refund_amount"`
|
||||||
|
OwnerIncomeAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"owner_income_amount"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
EvidenceURLS datatypes.JSON `json:"evidence_urls"`
|
||||||
|
OwnerAdjustmentReason string `json:"owner_adjustment_reason"`
|
||||||
|
OwnerAdjustedAt *time.Time `json:"owner_adjusted_at"`
|
||||||
|
RenterConfirmedAt *time.Time `json:"renter_confirmed_at"`
|
||||||
|
RenterRejectedAt *time.Time `json:"renter_rejected_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (OrderCheckout) TableName() string {
|
||||||
|
return "order_checkouts"
|
||||||
|
}
|
||||||
@@ -7,10 +7,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Query struct {
|
type Query struct {
|
||||||
ActorID uint64
|
ActorID uint64
|
||||||
Action string
|
Action string
|
||||||
BizType string
|
BizType string
|
||||||
Limit int
|
Page int
|
||||||
|
PageSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginatedResult struct {
|
||||||
|
Items interface{} `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LogDTO struct {
|
type LogDTO struct {
|
||||||
|
|||||||
@@ -18,17 +18,32 @@ func NewHandler(service *Service) *Handler {
|
|||||||
return &Handler{service: service}
|
return &Handler{service: service}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parsePagination(c *gin.Context) (int, int) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
if pageSize > 100 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
return page, pageSize
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) List(c *gin.Context) {
|
func (h *Handler) List(c *gin.Context) {
|
||||||
query, ok := parseQuery(c)
|
query, ok := parseQuery(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
items, err := h.service.List(query)
|
result, err := h.service.List(query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeAuditError(c, err)
|
writeAuditError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.OK(c, gin.H{"items": items})
|
response.OK(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseQuery(c *gin.Context) (Query, bool) {
|
func parseQuery(c *gin.Context) (Query, bool) {
|
||||||
@@ -41,16 +56,11 @@ func parseQuery(c *gin.Context) (Query, bool) {
|
|||||||
}
|
}
|
||||||
query.ActorID = value
|
query.ActorID = 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.Action = c.Query("action")
|
query.Action = c.Query("action")
|
||||||
query.BizType = c.Query("biz_type")
|
query.BizType = c.Query("biz_type")
|
||||||
|
page, pageSize := parsePagination(c)
|
||||||
|
query.Page = page
|
||||||
|
query.PageSize = pageSize
|
||||||
return query, true
|
return query, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package adminaudit
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/model"
|
||||||
|
|
||||||
"gorm.io/datatypes"
|
"gorm.io/datatypes"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -15,37 +17,42 @@ func NewRepository(db *gorm.DB) *Repository {
|
|||||||
return &Repository{db: db}
|
return &Repository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) List(query Query) ([]LogDTO, error) {
|
func (r *Repository) List(query Query) (*PaginatedResult, error) {
|
||||||
limit := query.Limit
|
|
||||||
if limit <= 0 || limit > 500 {
|
|
||||||
limit = 200
|
|
||||||
}
|
|
||||||
|
|
||||||
db := r.db.Table("audit_logs AS al").
|
db := r.db.Table("audit_logs AS al").
|
||||||
Select(`al.id, al.actor_type, al.actor_id, COALESCE(au.username, '') AS actor_username,
|
Select(`al.id, al.actor_type, al.actor_id, COALESCE(au.username, '') AS actor_username,
|
||||||
COALESCE(au.nickname, '') AS actor_nickname, al.action, al.biz_type, al.biz_id,
|
COALESCE(au.nickname, '') AS actor_nickname, al.action, al.biz_type, al.biz_id,
|
||||||
al.ip, al.user_agent, al.detail, al.created_at`).
|
al.ip, al.user_agent, al.detail, al.created_at`).
|
||||||
Joins("LEFT JOIN admin_users AS au ON au.id = al.actor_id AND al.actor_type = ?", "admin")
|
Joins("LEFT JOIN admin_users AS au ON au.id = al.actor_id AND al.actor_type = ?", "admin")
|
||||||
|
|
||||||
|
countDB := r.db.Model(&model.AuditLog{})
|
||||||
if query.ActorID > 0 {
|
if query.ActorID > 0 {
|
||||||
db = db.Where("al.actor_id = ?", query.ActorID)
|
db = db.Where("al.actor_id = ?", query.ActorID)
|
||||||
|
countDB = countDB.Where("actor_id = ?", query.ActorID)
|
||||||
}
|
}
|
||||||
if query.Action != "" {
|
if query.Action != "" {
|
||||||
db = db.Where("al.action = ?", query.Action)
|
db = db.Where("al.action = ?", query.Action)
|
||||||
|
countDB = countDB.Where("action = ?", query.Action)
|
||||||
}
|
}
|
||||||
if query.BizType != "" {
|
if query.BizType != "" {
|
||||||
db = db.Where("al.biz_type = ?", query.BizType)
|
db = db.Where("al.biz_type = ?", query.BizType)
|
||||||
|
countDB = countDB.Where("biz_type = ?", query.BizType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := countDB.Count(&total).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := (query.Page - 1) * query.PageSize
|
||||||
var rows []auditLogRow
|
var rows []auditLogRow
|
||||||
if err := db.Order("al.id DESC").Limit(limit).Scan(&rows).Error; err != nil {
|
if err := db.Order("al.id DESC").Offset(offset).Limit(query.PageSize).Scan(&rows).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items := make([]LogDTO, 0, len(rows))
|
items := make([]LogDTO, 0, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
items = append(items, row.toDTO())
|
items = append(items, row.toDTO())
|
||||||
}
|
}
|
||||||
return items, nil
|
return &PaginatedResult{Items: items, Total: total, Page: query.Page, PageSize: query.PageSize}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type auditLogRow struct {
|
type auditLogRow struct {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ func NewService(repo *Repository) *Service {
|
|||||||
return &Service{repo: repo}
|
return &Service{repo: repo}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) List(query Query) ([]LogDTO, error) {
|
func (s *Service) List(query Query) (*PaginatedResult, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,10 +65,30 @@ func (h *Handler) Logout(c *gin.Context) {
|
|||||||
response.OK(c, gin.H{"logged_out": true})
|
response.OK(c, gin.H{"logged_out": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AdminRefreshRequest struct {
|
||||||
|
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Refresh(c *gin.Context) {
|
||||||
|
var req AdminRefreshRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "refresh_token 不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokens, err := h.service.Refresh(req.RefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
writeAdminAuthError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, tokens)
|
||||||
|
}
|
||||||
|
|
||||||
func writeAdminAuthError(c *gin.Context, err error) {
|
func writeAdminAuthError(c *gin.Context, err error) {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrDependencyUnavailable):
|
case errors.Is(err, ErrDependencyUnavailable):
|
||||||
response.ServiceUnavailable(c, "数据库未连接")
|
response.ServiceUnavailable(c, "数据库未连接")
|
||||||
|
case errors.Is(err, ErrInvalidRefreshToken):
|
||||||
|
response.Unauthorized(c, "刷新令牌无效或已过期")
|
||||||
case errors.Is(err, ErrInvalidCredential):
|
case errors.Is(err, ErrInvalidCredential):
|
||||||
response.BadRequest(c, "用户名或密码错误")
|
response.BadRequest(c, "用户名或密码错误")
|
||||||
case errors.Is(err, ErrCaptchaInvalid):
|
case errors.Is(err, ErrCaptchaInvalid):
|
||||||
|
|||||||
@@ -1,20 +1,40 @@
|
|||||||
package adminauth
|
package adminauth
|
||||||
|
|
||||||
import "errors"
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/modules/auth"
|
||||||
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
||||||
ErrInvalidCredential = errors.New("invalid credential")
|
ErrInvalidCredential = errors.New("invalid credential")
|
||||||
ErrCaptchaInvalid = errors.New("captcha invalid")
|
ErrCaptchaInvalid = errors.New("captcha invalid")
|
||||||
ErrAdminDisabled = errors.New("admin disabled")
|
ErrAdminDisabled = errors.New("admin disabled")
|
||||||
|
ErrInvalidRefreshToken = errors.New("invalid refresh token")
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
repo *Repository
|
repo *Repository
|
||||||
|
jwt *auth.JWTManager
|
||||||
|
}
|
||||||
|
func NewService(repo *Repository, jwt *auth.JWTManager) *Service {
|
||||||
|
return &Service{repo: repo, jwt: jwt}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(repo *Repository) *Service {
|
func (s *Service) Refresh(refreshToken string) (*auth.TokenPair, error) {
|
||||||
return &Service{repo: repo}
|
if s.jwt == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
claims, err := s.jwt.ParseSubject(refreshToken, "refresh", "admin")
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrInvalidRefreshToken
|
||||||
|
}
|
||||||
|
pair, err := s.jwt.GenerateSubjectPair(claims.UserID, claims.Phone, "admin")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &pair, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Captcha() (*CaptchaDTO, error) {
|
func (s *Service) Captcha() (*CaptchaDTO, error) {
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ func (r *Repository) Summary() (*DashboardDTO, error) {
|
|||||||
if err := r.db.Model(&model.RentalOrder{}).Where("status = ? AND handoff_status IN ?", "pending_handoff", []string{"pending_owner", "pending_renter_confirm"}).Count(&pending.PendingHandoffs).Error; err != nil {
|
if err := r.db.Model(&model.RentalOrder{}).Where("status = ? AND handoff_status IN ?", "pending_handoff", []string{"pending_owner", "pending_renter_confirm"}).Count(&pending.PendingHandoffs).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := r.db.Model(&model.RentalOrder{}).Where("status = ?", "pending_return_confirm").Count(&pending.PendingReturnConfirms).Error; err != nil {
|
if err := r.db.Model(&model.RentalOrder{}).Where("status IN ?", []string{"pending_checkout_confirm", "pending_checkout_accept"}).Count(&pending.PendingReturnConfirms).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
recentOrders, err := r.recentOrders()
|
recentOrders, err := r.recentOrders()
|
||||||
|
|||||||
@@ -17,7 +17,13 @@ type UserDTO struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FreezeRequest struct {
|
type FreezeRequest struct {
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PaginatedResult struct {
|
||||||
|
Items interface{} `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,13 +19,29 @@ func NewHandler(service *Service) *Handler {
|
|||||||
return &Handler{service: service}
|
return &Handler{service: service}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parsePagination(c *gin.Context) (int, int) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
if pageSize > 100 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
return page, pageSize
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) List(c *gin.Context) {
|
func (h *Handler) List(c *gin.Context) {
|
||||||
items, err := h.service.List()
|
page, pageSize := parsePagination(c)
|
||||||
|
result, err := h.service.List(page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeAdminUserError(c, err)
|
writeAdminUserError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.OK(c, gin.H{"items": items})
|
response.OK(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) Freeze(c *gin.Context) {
|
func (h *Handler) Freeze(c *gin.Context) {
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ func NewRepository(db *gorm.DB) *Repository {
|
|||||||
return &Repository{db: db}
|
return &Repository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) List() ([]UserDTO, error) {
|
func (r *Repository) List(page, pageSize int) (*PaginatedResult, error) {
|
||||||
|
var total int64
|
||||||
|
if err := r.db.Model(&model.User{}).Count(&total).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
var rows []userRow
|
var rows []userRow
|
||||||
err := r.db.Table("users AS u").
|
err := r.db.Table("users AS u").
|
||||||
Select(`u.*,
|
Select(`u.*,
|
||||||
@@ -35,7 +40,7 @@ func (r *Repository) List() ([]UserDTO, error) {
|
|||||||
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 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").
|
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").
|
Order("u.id DESC").
|
||||||
Limit(200).
|
Offset(offset).Limit(pageSize).
|
||||||
Scan(&rows).Error
|
Scan(&rows).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -44,7 +49,7 @@ func (r *Repository) List() ([]UserDTO, error) {
|
|||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
items = append(items, row.toDTO())
|
items = append(items, row.toDTO())
|
||||||
}
|
}
|
||||||
return items, nil
|
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) Freeze(adminID uint64, userID uint64, req FreezeRequest, meta AuditMeta) (*UserDTO, error) {
|
func (r *Repository) Freeze(adminID uint64, userID uint64, req FreezeRequest, meta AuditMeta) (*UserDTO, error) {
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ func NewService(repo *Repository) *Service {
|
|||||||
return &Service{repo: repo}
|
return &Service{repo: repo}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) List() ([]UserDTO, error) {
|
func (s *Service) List(page, pageSize int) (*PaginatedResult, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
return s.repo.List()
|
return s.repo.List(page, pageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Freeze(adminID uint64, userID uint64, req FreezeRequest, meta AuditMeta) (*UserDTO, error) {
|
func (s *Service) Freeze(adminID uint64, userID uint64, req FreezeRequest, meta AuditMeta) (*UserDTO, error) {
|
||||||
|
|||||||
@@ -36,8 +36,14 @@ type ArbitrateRequest struct {
|
|||||||
Remark string `json:"remark" binding:"required"`
|
Remark string `json:"remark" binding:"required"`
|
||||||
Amount float64 `json:"amount"`
|
Amount float64 `json:"amount"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuditMeta struct {
|
type AuditMeta struct {
|
||||||
IP string
|
IP string
|
||||||
UserAgent string
|
UserAgent string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PaginatedResult struct {
|
||||||
|
Items []DisputeDTO `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,12 +48,13 @@ func (h *Handler) List(c *gin.Context) {
|
|||||||
response.Unauthorized(c, "缺少用户上下文")
|
response.Unauthorized(c, "缺少用户上下文")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
items, err := h.service.ListForUser(userID)
|
page, pageSize := parsePagination(c)
|
||||||
|
result, err := h.service.ListForUser(userID, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeDisputeError(c, err)
|
writeDisputeError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.OK(c, gin.H{"items": items})
|
response.OK(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) Detail(c *gin.Context) {
|
func (h *Handler) Detail(c *gin.Context) {
|
||||||
@@ -75,12 +76,13 @@ func (h *Handler) Detail(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) AdminList(c *gin.Context) {
|
func (h *Handler) AdminList(c *gin.Context) {
|
||||||
items, err := h.service.ListAdmin()
|
page, pageSize := parsePagination(c)
|
||||||
|
result, err := h.service.ListAdmin(page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeDisputeError(c, err)
|
writeDisputeError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.OK(c, gin.H{"items": items})
|
response.OK(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) AdminArbitrate(c *gin.Context) {
|
func (h *Handler) AdminArbitrate(c *gin.Context) {
|
||||||
@@ -136,6 +138,21 @@ func parseID(c *gin.Context) (uint64, bool) {
|
|||||||
return id, true
|
return id, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parsePagination(c *gin.Context) (int, int) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
if pageSize > 100 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
return page, pageSize
|
||||||
|
}
|
||||||
|
|
||||||
func writeDisputeError(c *gin.Context, err error) {
|
func writeDisputeError(c *gin.Context, err error) {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrDependencyUnavailable):
|
case errors.Is(err, ErrDependencyUnavailable):
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ func (r *Repository) Create(userID uint64, orderID uint64, req CreateRequest) (*
|
|||||||
if order.Status == "completed" || order.Status == "cancelled" || order.Status == "closed" {
|
if order.Status == "completed" || order.Status == "cancelled" || order.Status == "closed" {
|
||||||
return ErrInvalidDispute
|
return ErrInvalidDispute
|
||||||
}
|
}
|
||||||
|
isCheckoutDispute := order.Status == "pending_checkout_confirm" || order.Status == "pending_checkout_accept"
|
||||||
var count int64
|
var count int64
|
||||||
if err := tx.Model(&model.Dispute{}).
|
if err := tx.Model(&model.Dispute{}).
|
||||||
Where("order_id = ? AND status IN ?", order.ID, []string{"open", "processing"}).
|
Where("order_id = ? AND status IN ?", order.ID, []string{"open", "processing"}).
|
||||||
@@ -57,7 +58,7 @@ func (r *Repository) Create(userID uint64, orderID uint64, req CreateRequest) (*
|
|||||||
OrderID: order.ID,
|
OrderID: order.ID,
|
||||||
InitiatorID: userID,
|
InitiatorID: userID,
|
||||||
TargetUserID: targetID,
|
TargetUserID: targetID,
|
||||||
Type: req.Type,
|
Type: disputeType(req.Type, isCheckoutDispute),
|
||||||
Status: "open",
|
Status: "open",
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
EvidenceURLS: evidence,
|
EvidenceURLS: evidence,
|
||||||
@@ -65,17 +66,42 @@ func (r *Repository) Create(userID uint64, orderID uint64, req CreateRequest) (*
|
|||||||
if err := tx.Create(&row).Error; err != nil {
|
if err := tx.Create(&row).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
order.Status = "disputing"
|
if isCheckoutDispute {
|
||||||
|
now := time.Now()
|
||||||
|
order.Status = "checkout_disputing"
|
||||||
|
order.HandoffStatus = "checkout_disputed"
|
||||||
|
order.SettlementStatus = "disputed"
|
||||||
|
updates := map[string]any{
|
||||||
|
"status": "disputed",
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
if userID == order.RenterID {
|
||||||
|
updates["renter_rejected_at"] = now
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.OrderCheckout{}).
|
||||||
|
Where("order_id = ? AND status IN ?", order.ID, []string{"submitted", "countered"}).
|
||||||
|
Updates(updates).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
order.Status = "disputing"
|
||||||
|
}
|
||||||
if err := tx.Save(&order).Error; err != nil {
|
if err := tx.Save(&order).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
disputeID := row.ID
|
disputeID := row.ID
|
||||||
|
title := "订单进入申诉"
|
||||||
|
content := "对方已发起申诉,请等待客服仲裁或补充沟通记录。"
|
||||||
|
if isCheckoutDispute {
|
||||||
|
title = "订单进入结账争议"
|
||||||
|
content = "对方已发起结账争议,请等待客服仲裁或补充结账证据。"
|
||||||
|
}
|
||||||
if err := notification.Append(tx,
|
if err := notification.Append(tx,
|
||||||
notification.Entry{
|
notification.Entry{
|
||||||
UserID: targetID,
|
UserID: targetID,
|
||||||
Type: "dispute",
|
Type: "dispute",
|
||||||
Title: "订单进入申诉",
|
Title: title,
|
||||||
Content: "对方已发起申诉,请等待客服仲裁或补充沟通记录。",
|
Content: content,
|
||||||
BizType: "dispute",
|
BizType: "dispute",
|
||||||
BizID: &disputeID,
|
BizID: &disputeID,
|
||||||
},
|
},
|
||||||
@@ -99,16 +125,23 @@ func (r *Repository) Create(userID uint64, orderID uint64, req CreateRequest) (*
|
|||||||
return r.FindForUser(userID, createdID)
|
return r.FindForUser(userID, createdID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) ListForUser(userID uint64) ([]DisputeDTO, error) {
|
func (r *Repository) ListForUser(userID uint64, page, pageSize int) (*PaginatedResult, error) {
|
||||||
|
conditions := r.db.Model(&model.Dispute{}).Where("initiator_id = ? OR target_user_id = ?", userID, userID)
|
||||||
|
var total int64
|
||||||
|
if err := conditions.Count(&total).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
var rows []disputeRow
|
var rows []disputeRow
|
||||||
err := r.baseQuery().
|
err := r.baseQuery().
|
||||||
Where("d.initiator_id = ? OR d.target_user_id = ?", userID, userID).
|
Where("d.initiator_id = ? OR d.target_user_id = ?", userID, userID).
|
||||||
Order("d.id DESC").
|
Order("d.id DESC").
|
||||||
|
Offset(offset).Limit(pageSize).
|
||||||
Scan(&rows).Error
|
Scan(&rows).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return toDTOs(rows), nil
|
return &PaginatedResult{Items: toDTOs(rows), Total: total, Page: page, PageSize: pageSize}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) FindForUser(userID uint64, id uint64) (*DisputeDTO, error) {
|
func (r *Repository) FindForUser(userID uint64, id uint64) (*DisputeDTO, error) {
|
||||||
@@ -122,13 +155,18 @@ func (r *Repository) FindForUser(userID uint64, id uint64) (*DisputeDTO, error)
|
|||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) ListAdmin() ([]DisputeDTO, error) {
|
func (r *Repository) ListAdmin(page, pageSize int) (*PaginatedResult, error) {
|
||||||
|
var total int64
|
||||||
|
if err := r.db.Model(&model.Dispute{}).Count(&total).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
var rows []disputeRow
|
var rows []disputeRow
|
||||||
err := r.baseQuery().Order("d.id DESC").Limit(200).Scan(&rows).Error
|
err := r.baseQuery().Order("d.id DESC").Offset(offset).Limit(pageSize).Scan(&rows).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return toDTOs(rows), nil
|
return &PaginatedResult{Items: toDTOs(rows), Total: total, Page: page, PageSize: pageSize}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest, meta AuditMeta) (*DisputeDTO, error) {
|
func (r *Repository) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest, meta AuditMeta) (*DisputeDTO, error) {
|
||||||
@@ -177,6 +215,9 @@ func (r *Repository) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest,
|
|||||||
if req.Result == "order_close" {
|
if req.Result == "order_close" {
|
||||||
listing.Status = "offline"
|
listing.Status = "offline"
|
||||||
account.Status = "offline"
|
account.Status = "offline"
|
||||||
|
} else if req.Result == "mark_abnormal" {
|
||||||
|
listing.Status = "abnormal"
|
||||||
|
account.Status = "abnormal"
|
||||||
} else {
|
} else {
|
||||||
listing.Status = "published"
|
listing.Status = "published"
|
||||||
account.Status = "published"
|
account.Status = "published"
|
||||||
@@ -335,6 +376,8 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest) (
|
|||||||
addRenterRefund(order.DepositAmount-deductAmount, "仲裁退回剩余押金给租客")
|
addRenterRefund(order.DepositAmount-deductAmount, "仲裁退回剩余押金给租客")
|
||||||
case "order_close":
|
case "order_close":
|
||||||
// Only release frozen funds. No available-balance settlement happens in development mode.
|
// Only release frozen funds. No available-balance settlement happens in development mode.
|
||||||
|
case "mark_abnormal":
|
||||||
|
// 标记异常只释放冻结账务,后续由客服继续线下复核。
|
||||||
default:
|
default:
|
||||||
return settlement, ErrInvalidDispute
|
return settlement, ErrInvalidDispute
|
||||||
}
|
}
|
||||||
@@ -395,11 +438,20 @@ func arbitrateOrderStatus(result string) string {
|
|||||||
switch result {
|
switch result {
|
||||||
case "full_refund", "partial_refund", "order_close":
|
case "full_refund", "partial_refund", "order_close":
|
||||||
return "closed"
|
return "closed"
|
||||||
|
case "mark_abnormal":
|
||||||
|
return "abnormal"
|
||||||
default:
|
default:
|
||||||
return "completed"
|
return "completed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func disputeType(input string, isCheckoutDispute bool) string {
|
||||||
|
if isCheckoutDispute {
|
||||||
|
return "checkout_dispute"
|
||||||
|
}
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizType string, bizID uint64, meta AuditMeta, detail map[string]any) error {
|
func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizType string, bizID uint64, meta AuditMeta, detail map[string]any) error {
|
||||||
raw, err := json.Marshal(detail)
|
raw, err := json.Marshal(detail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ func (s *Service) Create(userID uint64, orderID uint64, req CreateRequest) (*Dis
|
|||||||
return s.repo.Create(userID, orderID, req)
|
return s.repo.Create(userID, orderID, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ListForUser(userID uint64) ([]DisputeDTO, error) {
|
func (s *Service) ListForUser(userID uint64, page, pageSize int) (*PaginatedResult, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
return s.repo.ListForUser(userID)
|
return s.repo.ListForUser(userID, page, pageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) FindForUser(userID uint64, id uint64) (*DisputeDTO, error) {
|
func (s *Service) FindForUser(userID uint64, id uint64) (*DisputeDTO, error) {
|
||||||
@@ -42,11 +42,11 @@ func (s *Service) FindForUser(userID uint64, id uint64) (*DisputeDTO, error) {
|
|||||||
return s.repo.FindForUser(userID, id)
|
return s.repo.FindForUser(userID, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ListAdmin() ([]DisputeDTO, error) {
|
func (s *Service) ListAdmin(page, pageSize int) (*PaginatedResult, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
return s.repo.ListAdmin()
|
return s.repo.ListAdmin(page, pageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest, meta AuditMeta) (*DisputeDTO, error) {
|
func (s *Service) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest, meta AuditMeta) (*DisputeDTO, error) {
|
||||||
|
|||||||
@@ -56,12 +56,19 @@ type AdminListQuery struct {
|
|||||||
OwnerID uint64
|
OwnerID uint64
|
||||||
Status string
|
Status string
|
||||||
ReviewStatus string
|
ReviewStatus string
|
||||||
Limit int
|
Page int
|
||||||
|
PageSize int
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminActionRequest struct {
|
type AdminActionRequest struct {
|
||||||
Reason string `json:"reason" binding:"required"`
|
Reason string `json:"reason" binding:"required"`
|
||||||
}
|
}
|
||||||
|
type PaginatedResult struct {
|
||||||
|
Items []ListingDTO `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
}
|
||||||
|
|
||||||
type AuditMeta struct {
|
type AuditMeta struct {
|
||||||
IP string
|
IP string
|
||||||
|
|||||||
@@ -82,12 +82,13 @@ func (h *Handler) SubmitReview(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) ListPendingReview(c *gin.Context) {
|
func (h *Handler) ListPendingReview(c *gin.Context) {
|
||||||
items, err := h.service.ListPendingReview()
|
page, pageSize := parsePagination(c)
|
||||||
|
result, err := h.service.ListPendingReview(page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeListingError(c, err)
|
writeListingError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.OK(c, gin.H{"items": items})
|
response.OK(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) ListAdmin(c *gin.Context) {
|
func (h *Handler) ListAdmin(c *gin.Context) {
|
||||||
@@ -95,12 +96,12 @@ func (h *Handler) ListAdmin(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
items, err := h.service.ListAdmin(query)
|
result, err := h.service.ListAdmin(query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeListingError(c, err)
|
writeListingError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.OK(c, gin.H{"items": items})
|
response.OK(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) FindAdmin(c *gin.Context) {
|
func (h *Handler) FindAdmin(c *gin.Context) {
|
||||||
@@ -196,12 +197,13 @@ func (h *Handler) Offline(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) ListPublic(c *gin.Context) {
|
func (h *Handler) ListPublic(c *gin.Context) {
|
||||||
items, err := h.service.ListPublic()
|
page, pageSize := parsePagination(c)
|
||||||
|
result, err := h.service.ListPublic(page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeListingError(c, err)
|
writeListingError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.OK(c, gin.H{"items": items})
|
response.OK(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) FindPublic(c *gin.Context) {
|
func (h *Handler) FindPublic(c *gin.Context) {
|
||||||
@@ -276,12 +278,13 @@ func (h *Handler) ListMine(c *gin.Context) {
|
|||||||
response.Unauthorized(c, "缺少用户上下文")
|
response.Unauthorized(c, "缺少用户上下文")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
items, err := h.service.ListMine(ownerID)
|
page, pageSize := parsePagination(c)
|
||||||
|
result, err := h.service.ListMine(ownerID, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeListingError(c, err)
|
writeListingError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.OK(c, gin.H{"items": items})
|
response.OK(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) FindMine(c *gin.Context) {
|
func (h *Handler) FindMine(c *gin.Context) {
|
||||||
@@ -339,19 +342,27 @@ func parseAdminListQuery(c *gin.Context) (AdminListQuery, bool) {
|
|||||||
}
|
}
|
||||||
query.OwnerID = value
|
query.OwnerID = 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.Status = c.Query("status")
|
query.Status = c.Query("status")
|
||||||
query.ReviewStatus = c.Query("review_status")
|
query.ReviewStatus = c.Query("review_status")
|
||||||
|
query.Page, query.PageSize = parsePagination(c)
|
||||||
return query, true
|
return query, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parsePagination(c *gin.Context) (int, int) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
if pageSize > 100 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
return page, pageSize
|
||||||
|
}
|
||||||
|
|
||||||
func writeListingError(c *gin.Context, err error) {
|
func writeListingError(c *gin.Context, err error) {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrDependencyUnavailable):
|
case errors.Is(err, ErrDependencyUnavailable):
|
||||||
|
|||||||
@@ -158,26 +158,41 @@ func (r *Repository) SubmitReview(ownerID uint64, listingID uint64, reviewRequir
|
|||||||
return dto, err
|
return dto, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) ListPendingReview() ([]ListingDTO, error) {
|
func (r *Repository) ListPendingReview(page, pageSize int) (*PaginatedResult, error) {
|
||||||
|
conditions := r.db.Model(&model.RentalListing{}).Where("review_status = ?", "pending")
|
||||||
|
var total int64
|
||||||
|
if err := conditions.Count(&total).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
var rows []listingRow
|
var rows []listingRow
|
||||||
err := r.baseQuery().
|
err := r.listQuery().
|
||||||
Where("l.review_status = ?", "pending").
|
Where("l.review_status = ?", "pending").
|
||||||
Order("l.updated_at ASC, l.id ASC").
|
Order("l.updated_at ASC, l.id ASC").
|
||||||
Limit(200).
|
Offset(offset).Limit(pageSize).
|
||||||
Scan(&rows).Error
|
Scan(&rows).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return rowsToDTO(rows), nil
|
return &PaginatedResult{Items: rowsToDTO(rows), Total: total, Page: page, PageSize: pageSize}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) ListAdmin(query AdminListQuery) ([]ListingDTO, error) {
|
func (r *Repository) ListAdmin(query AdminListQuery) (*PaginatedResult, error) {
|
||||||
limit := query.Limit
|
conditions := r.db.Model(&model.RentalListing{})
|
||||||
if limit <= 0 || limit > 500 {
|
if query.OwnerID > 0 {
|
||||||
limit = 200
|
conditions = conditions.Where("owner_id = ?", query.OwnerID)
|
||||||
}
|
}
|
||||||
|
if query.Status != "" {
|
||||||
db := r.baseQuery()
|
conditions = conditions.Where("status = ?", query.Status)
|
||||||
|
}
|
||||||
|
if query.ReviewStatus != "" {
|
||||||
|
conditions = conditions.Where("review_status = ?", query.ReviewStatus)
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := conditions.Count(&total).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
db := r.listQuery()
|
||||||
if query.OwnerID > 0 {
|
if query.OwnerID > 0 {
|
||||||
db = db.Where("l.owner_id = ?", query.OwnerID)
|
db = db.Where("l.owner_id = ?", query.OwnerID)
|
||||||
}
|
}
|
||||||
@@ -187,13 +202,13 @@ func (r *Repository) ListAdmin(query AdminListQuery) ([]ListingDTO, error) {
|
|||||||
if query.ReviewStatus != "" {
|
if query.ReviewStatus != "" {
|
||||||
db = db.Where("l.review_status = ?", query.ReviewStatus)
|
db = db.Where("l.review_status = ?", query.ReviewStatus)
|
||||||
}
|
}
|
||||||
|
offset := (query.Page - 1) * query.PageSize
|
||||||
var rows []listingRow
|
var rows []listingRow
|
||||||
err := db.Order("l.id DESC").Limit(limit).Scan(&rows).Error
|
err := db.Order("l.id DESC").Offset(offset).Limit(query.PageSize).Scan(&rows).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return rowsToDTO(rows), nil
|
return &PaginatedResult{Items: rowsToDTO(rows), Total: total, Page: query.Page, PageSize: query.PageSize}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) FindAdmin(listingID uint64) (*ListingDTO, error) {
|
func (r *Repository) FindAdmin(listingID uint64) (*ListingDTO, error) {
|
||||||
@@ -358,29 +373,43 @@ func (r *Repository) Offline(ownerID uint64, listingID uint64) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) ListPublic() ([]ListingDTO, error) {
|
func (r *Repository) ListPublic(page, pageSize int) (*PaginatedResult, error) {
|
||||||
|
conditions := r.db.Model(&model.RentalListing{}).Where("status = ? AND review_status = ?", "published", "approved")
|
||||||
|
var total int64
|
||||||
|
if err := conditions.Count(&total).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
var rows []listingRow
|
var rows []listingRow
|
||||||
err := r.baseQuery().
|
err := r.listQuery().
|
||||||
Where("l.status = ? AND l.review_status = ?", "published", "approved").
|
Where("l.status = ? AND l.review_status = ?", "published", "approved").
|
||||||
Order("l.published_at DESC, l.id DESC").
|
Order("l.published_at DESC, l.id DESC").
|
||||||
Limit(100).
|
Offset(offset).Limit(pageSize).
|
||||||
Scan(&rows).Error
|
Scan(&rows).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return publicListings(rowsToDTO(rows)), nil
|
items := publicListings(rowsToDTO(rows))
|
||||||
|
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) ListMine(ownerID uint64) ([]ListingDTO, error) {
|
func (r *Repository) ListMine(ownerID uint64, page, pageSize int) (*PaginatedResult, error) {
|
||||||
|
conditions := r.db.Model(&model.RentalListing{}).Where("owner_id = ?", ownerID)
|
||||||
|
var total int64
|
||||||
|
if err := conditions.Count(&total).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
var rows []listingRow
|
var rows []listingRow
|
||||||
err := r.baseQuery().
|
err := r.listQuery().
|
||||||
Where("l.owner_id = ?", ownerID).
|
Where("l.owner_id = ?", ownerID).
|
||||||
Order("l.id DESC").
|
Order("l.id DESC").
|
||||||
|
Offset(offset).Limit(pageSize).
|
||||||
Scan(&rows).Error
|
Scan(&rows).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return rowsToDTO(rows), nil
|
return &PaginatedResult{Items: rowsToDTO(rows), Total: total, Page: page, PageSize: pageSize}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) FindPublic(id uint64) (*ListingDTO, error) {
|
func (r *Repository) FindPublic(id uint64) (*ListingDTO, error) {
|
||||||
@@ -449,6 +478,17 @@ func (r *Repository) baseQuery() *gorm.DB {
|
|||||||
Joins("LEFT JOIN users AS u ON u.id = l.owner_id")
|
Joins("LEFT JOIN users AS u ON u.id = l.owner_id")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listQuery returns a SELECT-optimized query for list endpoints (excludes heavy fields like description, screenshot_urls, asset_summary).
|
||||||
|
func (r *Repository) listQuery() *gorm.DB {
|
||||||
|
return r.db.Table("rental_listings AS l").
|
||||||
|
Select(`l.id, l.account_id, l.owner_id, l.price_hourly, l.price_daily, l.price_weekly, l.deposit_amount,
|
||||||
|
l.status, l.review_status, l.review_reason, l.published_at, l.created_at, l.updated_at,
|
||||||
|
a.title, a.game_name, a.server_region, a.login_platform, a.rank_level, a.haf_coin_amount,
|
||||||
|
COALESCE(u.phone, '') AS owner_phone, COALESCE(u.nickname, '') AS owner_nickname`).
|
||||||
|
Joins("JOIN game_accounts AS a ON a.id = l.account_id").
|
||||||
|
Joins("LEFT JOIN users AS u ON u.id = l.owner_id")
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) findForReviewUpdate(tx *gorm.DB, listingID uint64) (*model.RentalListing, *model.GameAccount, error) {
|
func (r *Repository) findForReviewUpdate(tx *gorm.DB, listingID uint64) (*model.RentalListing, *model.GameAccount, error) {
|
||||||
var listing model.RentalListing
|
var listing model.RentalListing
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, listingID).Error; err != nil {
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, listingID).Error; err != nil {
|
||||||
|
|||||||
@@ -93,14 +93,14 @@ func (s *Service) SubmitReview(ownerID uint64, id uint64) (*ListingDTO, error) {
|
|||||||
return s.repo.SubmitReview(ownerID, id, reviewRequired)
|
return s.repo.SubmitReview(ownerID, id, reviewRequired)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ListPendingReview() ([]ListingDTO, error) {
|
func (s *Service) ListPendingReview(page, pageSize int) (*PaginatedResult, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
return s.repo.ListPendingReview()
|
return s.repo.ListPendingReview(page, pageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ListAdmin(query AdminListQuery) ([]ListingDTO, error) {
|
func (s *Service) ListAdmin(query AdminListQuery) (*PaginatedResult, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
@@ -158,18 +158,18 @@ func (s *Service) Offline(ownerID uint64, id uint64) error {
|
|||||||
return s.repo.Offline(ownerID, id)
|
return s.repo.Offline(ownerID, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ListPublic() ([]ListingDTO, error) {
|
func (s *Service) ListPublic(page, pageSize int) (*PaginatedResult, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
return s.repo.ListPublic()
|
return s.repo.ListPublic(page, pageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ListMine(ownerID uint64) ([]ListingDTO, error) {
|
func (s *Service) ListMine(ownerID uint64, page, pageSize int) (*PaginatedResult, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
return s.repo.ListMine(ownerID)
|
return s.repo.ListMine(ownerID, page, pageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) FindPublic(id uint64) (*ListingDTO, error) {
|
func (s *Service) FindPublic(id uint64) (*ListingDTO, error) {
|
||||||
|
|||||||
@@ -13,3 +13,10 @@ type NotificationDTO struct {
|
|||||||
ReadAt *time.Time `json:"read_at"`
|
ReadAt *time.Time `json:"read_at"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PaginatedResult struct {
|
||||||
|
Items interface{} `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,18 +18,34 @@ func NewHandler(service *Service) *Handler {
|
|||||||
return &Handler{service: service}
|
return &Handler{service: service}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parsePagination(c *gin.Context) (int, int) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
if pageSize > 100 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
return page, pageSize
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) List(c *gin.Context) {
|
func (h *Handler) List(c *gin.Context) {
|
||||||
userID, ok := currentUserID(c)
|
userID, ok := currentUserID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
response.Unauthorized(c, "缺少用户上下文")
|
response.Unauthorized(c, "缺少用户上下文")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
items, err := h.service.List(userID)
|
page, pageSize := parsePagination(c)
|
||||||
|
result, err := h.service.List(userID, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeNotificationError(c, err)
|
writeNotificationError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.OK(c, gin.H{"items": items})
|
response.OK(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) MarkRead(c *gin.Context) {
|
func (h *Handler) MarkRead(c *gin.Context) {
|
||||||
|
|||||||
@@ -25,16 +25,21 @@ func NewRepository(db *gorm.DB) *Repository {
|
|||||||
return &Repository{db: db}
|
return &Repository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) List(userID uint64) ([]NotificationDTO, error) {
|
func (r *Repository) List(userID uint64, page, pageSize int) (*PaginatedResult, error) {
|
||||||
|
var total int64
|
||||||
|
if err := r.db.Model(&model.Notification{}).Where("user_id = ?", userID).Count(&total).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
var rows []model.Notification
|
var rows []model.Notification
|
||||||
if err := r.db.Where("user_id = ?", userID).Order("id DESC").Limit(100).Find(&rows).Error; err != nil {
|
if err := r.db.Where("user_id = ?", userID).Order("id DESC").Offset(offset).Limit(pageSize).Find(&rows).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items := make([]NotificationDTO, 0, len(rows))
|
items := make([]NotificationDTO, 0, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
items = append(items, toDTO(row))
|
items = append(items, toDTO(row))
|
||||||
}
|
}
|
||||||
return items, nil
|
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) MarkRead(userID uint64, id uint64) error {
|
func (r *Repository) MarkRead(userID uint64, id uint64) error {
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ func NewService(repo *Repository) *Service {
|
|||||||
return &Service{repo: repo}
|
return &Service{repo: repo}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) List(userID uint64) ([]NotificationDTO, error) {
|
func (s *Service) List(userID uint64, page, pageSize int) (*PaginatedResult, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
return s.repo.List(userID)
|
return s.repo.List(userID, page, pageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) MarkRead(userID uint64, id uint64) error {
|
func (s *Service) MarkRead(userID uint64, id uint64) error {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type OrderDTO struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
HandoffStatus string `json:"handoff_status"`
|
HandoffStatus string `json:"handoff_status"`
|
||||||
SettlementStatus string `json:"settlement_status"`
|
SettlementStatus string `json:"settlement_status"`
|
||||||
|
Checkout *CheckoutDTO `json:"checkout,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -39,8 +40,21 @@ type SubmitHandoffRequest struct {
|
|||||||
Content string `json:"content" binding:"required"`
|
Content string `json:"content" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubmitReturnRequest struct {
|
type SubmitCheckoutRequest struct {
|
||||||
Content string `json:"content" binding:"required"`
|
Content string `json:"content" binding:"required"`
|
||||||
|
ConsumableAmount float64 `json:"consumable_amount"`
|
||||||
|
CoinConsumedM float64 `json:"coin_consumed_m"`
|
||||||
|
OtherAmount float64 `json:"other_amount"`
|
||||||
|
EvidenceURLS []string `json:"evidence_urls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CounterCheckoutRequest struct {
|
||||||
|
ConsumableAmount float64 `json:"consumable_amount"`
|
||||||
|
CoinConsumedM float64 `json:"coin_consumed_m"`
|
||||||
|
OtherAmount float64 `json:"other_amount"`
|
||||||
|
DepositDeductAmount float64 `json:"deposit_deduct_amount"`
|
||||||
|
Reason string `json:"reason" binding:"required"`
|
||||||
|
EvidenceURLS []string `json:"evidence_urls"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminActionRequest struct {
|
type AdminActionRequest struct {
|
||||||
@@ -63,3 +77,49 @@ type HandoffRecordDTO struct {
|
|||||||
ConfirmedByOwnerAt *time.Time `json:"confirmed_by_owner_at"`
|
ConfirmedByOwnerAt *time.Time `json:"confirmed_by_owner_at"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OrderListItemDTO struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
OrderNo string `json:"order_no"`
|
||||||
|
ListingTitle string `json:"listing_title"`
|
||||||
|
GameName string `json:"game_name"`
|
||||||
|
AccountTitle string `json:"account_title"`
|
||||||
|
RenterNickname string `json:"renter_nickname"`
|
||||||
|
OwnerNickname string `json:"owner_nickname"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
HandoffStatus string `json:"handoff_status"`
|
||||||
|
SettlementStatus string `json:"settlement_status"`
|
||||||
|
RentAmount float64 `json:"rent_amount"`
|
||||||
|
DepositAmount float64 `json:"deposit_amount"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginatedOrders struct {
|
||||||
|
Items []OrderListItemDTO `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CheckoutDTO struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
OrderID uint64 `json:"order_id"`
|
||||||
|
InitiatedBy uint64 `json:"initiated_by"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
RentAmount float64 `json:"rent_amount"`
|
||||||
|
DepositAmount float64 `json:"deposit_amount"`
|
||||||
|
ConsumableAmount float64 `json:"consumable_amount"`
|
||||||
|
CoinConsumedM float64 `json:"coin_consumed_m"`
|
||||||
|
OtherAmount float64 `json:"other_amount"`
|
||||||
|
DepositDeductAmount float64 `json:"deposit_deduct_amount"`
|
||||||
|
RenterRefundAmount float64 `json:"renter_refund_amount"`
|
||||||
|
OwnerIncomeAmount float64 `json:"owner_income_amount"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
EvidenceURLS []string `json:"evidence_urls"`
|
||||||
|
OwnerAdjustmentReason string `json:"owner_adjustment_reason"`
|
||||||
|
OwnerAdjustedAt *time.Time `json:"owner_adjusted_at"`
|
||||||
|
RenterConfirmedAt *time.Time `json:"renter_confirmed_at"`
|
||||||
|
RenterRejectedAt *time.Time `json:"renter_rejected_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,21 +44,43 @@ func (h *Handler) List(c *gin.Context) {
|
|||||||
response.Unauthorized(c, "缺少用户上下文")
|
response.Unauthorized(c, "缺少用户上下文")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
items, err := h.service.ListForUser(userID)
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
if pageSize > 100 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
result, err := h.service.ListForUser(userID, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeOrderError(c, err)
|
writeOrderError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.OK(c, gin.H{"items": items})
|
response.OK(c, gin.H{"items": result.Items, "total": result.Total, "page": result.Page, "page_size": result.PageSize})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) AdminList(c *gin.Context) {
|
func (h *Handler) AdminList(c *gin.Context) {
|
||||||
items, err := h.service.ListAdmin()
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
if pageSize > 100 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
result, err := h.service.ListAdmin(page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeOrderError(c, err)
|
writeOrderError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.OK(c, gin.H{"items": items})
|
response.OK(c, gin.H{"items": result.Items, "total": result.Total, "page": result.Page, "page_size": result.PageSize})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) AdminDetail(c *gin.Context) {
|
func (h *Handler) AdminDetail(c *gin.Context) {
|
||||||
@@ -210,7 +232,7 @@ func (h *Handler) HandoffRecords(c *gin.Context) {
|
|||||||
response.OK(c, gin.H{"items": items})
|
response.OK(c, gin.H{"items": items})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) SubmitReturn(c *gin.Context) {
|
func (h *Handler) SubmitCheckout(c *gin.Context) {
|
||||||
userID, ok := currentUserID(c)
|
userID, ok := currentUserID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
response.Unauthorized(c, "缺少用户上下文")
|
response.Unauthorized(c, "缺少用户上下文")
|
||||||
@@ -220,12 +242,12 @@ func (h *Handler) SubmitReturn(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req SubmitReturnRequest
|
var req SubmitCheckoutRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
response.BadRequest(c, "归还说明不能为空")
|
response.BadRequest(c, "结账说明不能为空")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
record, err := h.service.SubmitReturn(userID, id, req)
|
record, err := h.service.SubmitCheckout(userID, id, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeOrderError(c, err)
|
writeOrderError(c, err)
|
||||||
return
|
return
|
||||||
@@ -233,7 +255,7 @@ func (h *Handler) SubmitReturn(c *gin.Context) {
|
|||||||
response.Created(c, record)
|
response.Created(c, record)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) ConfirmReturn(c *gin.Context) {
|
func (h *Handler) ConfirmCheckout(c *gin.Context) {
|
||||||
userID, ok := currentUserID(c)
|
userID, ok := currentUserID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
response.Unauthorized(c, "缺少用户上下文")
|
response.Unauthorized(c, "缺少用户上下文")
|
||||||
@@ -243,7 +265,47 @@ func (h *Handler) ConfirmReturn(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.service.ConfirmReturn(userID, id); err != nil {
|
if err := h.service.ConfirmCheckout(userID, id); err != nil {
|
||||||
|
writeOrderError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"completed": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CounterCheckout(c *gin.Context) {
|
||||||
|
userID, ok := currentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少用户上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req CounterCheckoutRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "结账修正原因不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
checkout, err := h.service.CounterCheckout(userID, id, req)
|
||||||
|
if err != nil {
|
||||||
|
writeOrderError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, checkout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AcceptCheckout(c *gin.Context) {
|
||||||
|
userID, ok := currentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少用户上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.AcceptCheckout(userID, id); err != nil {
|
||||||
writeOrderError(c, err)
|
writeOrderError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -293,10 +355,16 @@ func writeOrderError(c *gin.Context, err error) {
|
|||||||
response.Error(c, http.StatusConflict, "order_cannot_handoff", "当前订单不能交接")
|
response.Error(c, http.StatusConflict, "order_cannot_handoff", "当前订单不能交接")
|
||||||
case errors.Is(err, ErrOrderCannotReceive):
|
case errors.Is(err, ErrOrderCannotReceive):
|
||||||
response.Error(c, http.StatusConflict, "order_cannot_receive", "当前订单不能确认收号")
|
response.Error(c, http.StatusConflict, "order_cannot_receive", "当前订单不能确认收号")
|
||||||
case errors.Is(err, ErrOrderCannotReturn):
|
|
||||||
response.Error(c, http.StatusConflict, "order_cannot_return", "当前订单不能归还")
|
|
||||||
case errors.Is(err, ErrOrderCannotComplete):
|
case errors.Is(err, ErrOrderCannotComplete):
|
||||||
response.Error(c, http.StatusConflict, "order_cannot_complete", "当前订单不能完成")
|
response.Error(c, http.StatusConflict, "order_cannot_complete", "当前订单不能完成")
|
||||||
|
case errors.Is(err, ErrCheckoutCannotSubmit):
|
||||||
|
response.Error(c, http.StatusConflict, "checkout_cannot_submit", "当前订单不能发起结账")
|
||||||
|
case errors.Is(err, ErrCheckoutCannotConfirm):
|
||||||
|
response.Error(c, http.StatusConflict, "checkout_cannot_confirm", "当前结账不能确认")
|
||||||
|
case errors.Is(err, ErrCheckoutCannotCounter):
|
||||||
|
response.Error(c, http.StatusConflict, "checkout_cannot_counter", "当前结账不能修改")
|
||||||
|
case errors.Is(err, ErrInvalidCheckoutAmount):
|
||||||
|
response.BadRequest(c, "结账金额不符合规则")
|
||||||
case errors.Is(err, ErrPermissionDenied):
|
case errors.Is(err, ErrPermissionDenied):
|
||||||
response.Error(c, http.StatusForbidden, "permission_denied", "无权操作该订单")
|
response.Error(c, http.StatusForbidden, "permission_denied", "无权操作该订单")
|
||||||
case IsNotFound(err):
|
case IsNotFound(err):
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -296,7 +297,7 @@ func (r *Repository) HandoffRecords(userID uint64, orderID uint64) ([]HandoffRec
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) SubmitReturn(userID uint64, orderID uint64, req SubmitReturnRequest) (*HandoffRecordDTO, error) {
|
func (r *Repository) SubmitCheckout(userID uint64, orderID uint64, req SubmitCheckoutRequest) (*HandoffRecordDTO, error) {
|
||||||
var recordID uint64
|
var recordID uint64
|
||||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
var order model.RentalOrder
|
var order model.RentalOrder
|
||||||
@@ -307,28 +308,43 @@ func (r *Repository) SubmitReturn(userID uint64, orderID uint64, req SubmitRetur
|
|||||||
return ErrPermissionDenied
|
return ErrPermissionDenied
|
||||||
}
|
}
|
||||||
if (order.Status != "renting" && order.Status != "overdue") || (order.HandoffStatus != "received" && order.HandoffStatus != "return_overdue") {
|
if (order.Status != "renting" && order.Status != "overdue") || (order.HandoffStatus != "received" && order.HandoffStatus != "return_overdue") {
|
||||||
return ErrOrderCannotReturn
|
return ErrCheckoutCannotSubmit
|
||||||
|
}
|
||||||
|
hasOpenCheckout, err := hasOpenCheckout(tx, order.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if hasOpenCheckout {
|
||||||
|
return ErrCheckoutCannotSubmit
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
record := model.HandoffRecord{
|
record := model.HandoffRecord{
|
||||||
OrderID: order.ID,
|
OrderID: order.ID,
|
||||||
FromUserID: order.RenterID,
|
FromUserID: order.RenterID,
|
||||||
ToUserID: order.OwnerID,
|
ToUserID: order.OwnerID,
|
||||||
Type: "renter_return",
|
Type: "renter_checkout",
|
||||||
Content: req.Content,
|
Content: req.Content,
|
||||||
}
|
}
|
||||||
if err := tx.Create(&record).Error; err != nil {
|
if err := tx.Create(&record).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
order.Status = "pending_return_confirm"
|
checkout, err := buildCheckout(order, order.RenterID, "submitted", req.Content, req.EvidenceURLS, req.ConsumableAmount, req.CoinConsumedM, req.OtherAmount, 0, false)
|
||||||
order.HandoffStatus = "pending_owner_return_confirm"
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Create(&checkout).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
order.Status = "pending_checkout_confirm"
|
||||||
|
order.HandoffStatus = "pending_owner_checkout"
|
||||||
|
order.SettlementStatus = "pending"
|
||||||
order.RentEndAt = &now
|
order.RentEndAt = &now
|
||||||
orderID := order.ID
|
orderID := order.ID
|
||||||
if err := notification.Append(tx, notification.Entry{
|
if err := notification.Append(tx, notification.Entry{
|
||||||
UserID: order.OwnerID,
|
UserID: order.OwnerID,
|
||||||
Type: "return",
|
Type: "checkout",
|
||||||
Title: "租客已提交归还",
|
Title: "租客已发起结账",
|
||||||
Content: "请检查账号状态,确认无误后完成订单。",
|
Content: "请检查账号状态和消耗明细,确认无误后完成结算。",
|
||||||
BizType: "order",
|
BizType: "order",
|
||||||
BizID: &orderID,
|
BizID: &orderID,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -346,7 +362,7 @@ func (r *Repository) SubmitReturn(userID uint64, orderID uint64, req SubmitRetur
|
|||||||
return r.findHandoffRecord(recordID)
|
return r.findHandoffRecord(recordID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) ConfirmReturn(userID uint64, orderID uint64) error {
|
func (r *Repository) ConfirmCheckout(userID uint64, orderID uint64) error {
|
||||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
var order model.RentalOrder
|
var order model.RentalOrder
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
||||||
@@ -355,125 +371,162 @@ func (r *Repository) ConfirmReturn(userID uint64, orderID uint64) error {
|
|||||||
if order.OwnerID != userID {
|
if order.OwnerID != userID {
|
||||||
return ErrPermissionDenied
|
return ErrPermissionDenied
|
||||||
}
|
}
|
||||||
if order.Status != "pending_return_confirm" || order.HandoffStatus != "pending_owner_return_confirm" {
|
if order.Status != "pending_checkout_confirm" || order.HandoffStatus != "pending_owner_checkout" {
|
||||||
return ErrOrderCannotComplete
|
return ErrCheckoutCannotConfirm
|
||||||
|
}
|
||||||
|
var checkout model.OrderCheckout
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Where("order_id = ? AND status = ?", order.ID, "submitted").
|
||||||
|
Order("id DESC").
|
||||||
|
First(&checkout).Error; err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if err := tx.Model(&model.HandoffRecord{}).
|
if err := tx.Model(&model.HandoffRecord{}).
|
||||||
Where("order_id = ? AND type = ?", order.ID, "renter_return").
|
Where("order_id = ? AND type = ?", order.ID, "renter_checkout").
|
||||||
Update("confirmed_by_owner_at", now).Error; err != nil {
|
Update("confirmed_by_owner_at", now).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var listing model.RentalListing
|
checkout.Status = "accepted"
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, order.ListingID).Error; err != nil {
|
checkout.OwnerAdjustedAt = &now
|
||||||
return err
|
return r.finalizeCheckout(tx, &order, &checkout, "号主已确认租客结账,订单完成。")
|
||||||
}
|
|
||||||
var account model.GameAccount
|
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, order.AccountID).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
order.Status = "completed"
|
|
||||||
order.HandoffStatus = "returned"
|
|
||||||
order.SettlementStatus = "settled"
|
|
||||||
order.SettledAt = &now
|
|
||||||
order.OwnerSettledAt = &now
|
|
||||||
orderID := order.ID
|
|
||||||
if err := wallet.AppendEntries(tx,
|
|
||||||
wallet.Entry{
|
|
||||||
UserID: order.RenterID,
|
|
||||||
OrderID: &orderID,
|
|
||||||
Direction: "out",
|
|
||||||
Amount: order.RentAmount + order.DepositAmount,
|
|
||||||
BalanceType: "frozen",
|
|
||||||
BizType: "order_settle",
|
|
||||||
BizNo: order.OrderNo,
|
|
||||||
Remark: "订单完成释放模拟冻结金额",
|
|
||||||
},
|
|
||||||
wallet.Entry{
|
|
||||||
UserID: order.OwnerID,
|
|
||||||
OrderID: &orderID,
|
|
||||||
Direction: "in",
|
|
||||||
Amount: order.RentAmount,
|
|
||||||
BalanceType: "available",
|
|
||||||
BizType: "owner_income",
|
|
||||||
BizNo: order.OrderNo,
|
|
||||||
Remark: "订单完成模拟结算订单金额",
|
|
||||||
},
|
|
||||||
wallet.Entry{
|
|
||||||
UserID: order.RenterID,
|
|
||||||
OrderID: &orderID,
|
|
||||||
Direction: "in",
|
|
||||||
Amount: order.DepositAmount,
|
|
||||||
BalanceType: "available",
|
|
||||||
BizType: "deposit_release",
|
|
||||||
BizNo: order.OrderNo,
|
|
||||||
Remark: "订单完成模拟退回押金",
|
|
||||||
},
|
|
||||||
); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := notification.Append(tx,
|
|
||||||
notification.Entry{
|
|
||||||
UserID: order.RenterID,
|
|
||||||
Type: "settlement",
|
|
||||||
Title: "订单已完成",
|
|
||||||
Content: "号主已确认归还,模拟押金已退回。",
|
|
||||||
BizType: "order",
|
|
||||||
BizID: &orderID,
|
|
||||||
},
|
|
||||||
notification.Entry{
|
|
||||||
UserID: order.OwnerID,
|
|
||||||
Type: "settlement",
|
|
||||||
Title: "订单已完成",
|
|
||||||
Content: "订单已完成,模拟订单金额已入账。",
|
|
||||||
BizType: "order",
|
|
||||||
BizID: &orderID,
|
|
||||||
},
|
|
||||||
); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
listing.Status = "published"
|
|
||||||
account.Status = "published"
|
|
||||||
if err := tx.Save(&order).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Save(&listing).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return tx.Save(&account).Error
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) ListForUser(userID uint64) ([]OrderDTO, error) {
|
func (r *Repository) CounterCheckout(userID uint64, orderID uint64, req CounterCheckoutRequest) (*CheckoutDTO, error) {
|
||||||
var rows []orderRow
|
var checkoutID uint64
|
||||||
err := r.baseQuery().
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
Where("o.renter_id = ?", userID).
|
var order model.RentalOrder
|
||||||
Order("o.id DESC").
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
||||||
Scan(&rows).Error
|
return err
|
||||||
|
}
|
||||||
|
if order.OwnerID != userID {
|
||||||
|
return ErrPermissionDenied
|
||||||
|
}
|
||||||
|
if order.Status != "pending_checkout_confirm" || order.HandoffStatus != "pending_owner_checkout" {
|
||||||
|
return ErrCheckoutCannotCounter
|
||||||
|
}
|
||||||
|
var checkout model.OrderCheckout
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Where("order_id = ? AND status = ?", order.ID, "submitted").
|
||||||
|
Order("id DESC").
|
||||||
|
First(&checkout).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
next, err := buildCheckout(order, checkout.InitiatedBy, "countered", checkout.Content, req.EvidenceURLS, req.ConsumableAmount, req.CoinConsumedM, req.OtherAmount, req.DepositDeductAmount, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
checkout.Status = "countered"
|
||||||
|
checkout.ConsumableAmount = next.ConsumableAmount
|
||||||
|
checkout.CoinConsumedM = next.CoinConsumedM
|
||||||
|
checkout.OtherAmount = next.OtherAmount
|
||||||
|
checkout.DepositDeductAmount = next.DepositDeductAmount
|
||||||
|
checkout.RenterRefundAmount = next.RenterRefundAmount
|
||||||
|
checkout.OwnerIncomeAmount = next.OwnerIncomeAmount
|
||||||
|
checkout.OwnerAdjustmentReason = req.Reason
|
||||||
|
checkout.OwnerAdjustedAt = &now
|
||||||
|
checkout.EvidenceURLS = next.EvidenceURLS
|
||||||
|
order.Status = "pending_checkout_accept"
|
||||||
|
order.HandoffStatus = "pending_renter_checkout"
|
||||||
|
order.SettlementStatus = "pending"
|
||||||
|
orderID := order.ID
|
||||||
|
if err := notification.Append(tx, notification.Entry{
|
||||||
|
UserID: order.RenterID,
|
||||||
|
Type: "checkout",
|
||||||
|
Title: "号主已修改结账金额",
|
||||||
|
Content: "请核对号主修正的消耗和结算金额。同意后订单完成;不同意可发起争议。",
|
||||||
|
BizType: "order",
|
||||||
|
BizID: &orderID,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Save(&order).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Save(&checkout).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
checkoutID = checkout.ID
|
||||||
|
return nil
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items := make([]OrderDTO, 0, len(rows))
|
checkout, err := r.findCheckout(checkoutID)
|
||||||
for _, row := range rows {
|
if err != nil {
|
||||||
items = append(items, row.toDTO())
|
return nil, err
|
||||||
}
|
}
|
||||||
return items, nil
|
dto := toCheckoutDTO(*checkout)
|
||||||
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) ListAdmin() ([]OrderDTO, error) {
|
func (r *Repository) AcceptCheckout(userID uint64, orderID uint64) error {
|
||||||
var rows []orderRow
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
err := r.adminQuery().
|
var order model.RentalOrder
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if order.RenterID != userID {
|
||||||
|
return ErrPermissionDenied
|
||||||
|
}
|
||||||
|
if order.Status != "pending_checkout_accept" || order.HandoffStatus != "pending_renter_checkout" {
|
||||||
|
return ErrCheckoutCannotConfirm
|
||||||
|
}
|
||||||
|
var checkout model.OrderCheckout
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Where("order_id = ? AND status = ?", order.ID, "countered").
|
||||||
|
Order("id DESC").
|
||||||
|
First(&checkout).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
checkout.Status = "accepted"
|
||||||
|
checkout.RenterConfirmedAt = &now
|
||||||
|
return r.finalizeCheckout(tx, &order, &checkout, "租客已确认修正结账,订单完成。")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ListForUser(userID uint64, page int, pageSize int) (*PaginatedOrders, error) {
|
||||||
|
var total int64
|
||||||
|
r.db.Model(&model.RentalOrder{}).Where("renter_id = ? OR owner_id = ?", userID, userID).Count(&total)
|
||||||
|
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
var rows []orderListRow
|
||||||
|
err := r.listItemBaseQuery().
|
||||||
|
Where("o.renter_id = ? OR o.owner_id = ?", userID, userID).
|
||||||
Order("o.id DESC").
|
Order("o.id DESC").
|
||||||
Limit(200).
|
Offset(offset).Limit(pageSize).
|
||||||
Scan(&rows).Error
|
Scan(&rows).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items := make([]OrderDTO, 0, len(rows))
|
items := make([]OrderListItemDTO, 0, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
items = append(items, row.toDTO())
|
items = append(items, row.toListItemDTO())
|
||||||
}
|
}
|
||||||
return items, nil
|
return &PaginatedOrders{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ListAdmin(page int, pageSize int) (*PaginatedOrders, error) {
|
||||||
|
var total int64
|
||||||
|
r.db.Model(&model.RentalOrder{}).Count(&total)
|
||||||
|
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
var rows []orderListRow
|
||||||
|
err := r.listItemBaseQuery().
|
||||||
|
Order("o.id DESC").
|
||||||
|
Offset(offset).Limit(pageSize).
|
||||||
|
Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]OrderListItemDTO, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
items = append(items, row.toListItemDTO())
|
||||||
|
}
|
||||||
|
return &PaginatedOrders{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) FindAdmin(orderID uint64) (*OrderDTO, error) {
|
func (r *Repository) FindAdmin(orderID uint64) (*OrderDTO, error) {
|
||||||
@@ -482,6 +535,7 @@ func (r *Repository) FindAdmin(orderID uint64) (*OrderDTO, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
dto := row.toDTO()
|
dto := row.toDTO()
|
||||||
|
dto.Checkout = r.latestCheckoutDTO(orderID)
|
||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -655,9 +709,175 @@ func (r *Repository) FindForUser(userID uint64, orderID uint64) (*OrderDTO, erro
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
dto := row.toDTO()
|
dto := row.toDTO()
|
||||||
|
dto.Checkout = r.latestCheckoutDTO(orderID)
|
||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, checkout *model.OrderCheckout, renterContent string) error {
|
||||||
|
var listing model.RentalListing
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, order.ListingID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var account model.GameAccount
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, order.AccountID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
order.Status = "completed"
|
||||||
|
order.HandoffStatus = "returned"
|
||||||
|
order.SettlementStatus = "settled"
|
||||||
|
order.SettledAt = &now
|
||||||
|
order.OwnerSettledAt = &now
|
||||||
|
listing.Status = "published"
|
||||||
|
account.Status = "published"
|
||||||
|
orderID := order.ID
|
||||||
|
if err := wallet.AppendEntries(tx,
|
||||||
|
wallet.Entry{
|
||||||
|
UserID: order.RenterID,
|
||||||
|
OrderID: &orderID,
|
||||||
|
Direction: "out",
|
||||||
|
Amount: order.RentAmount + order.DepositAmount,
|
||||||
|
BalanceType: "frozen",
|
||||||
|
BizType: "order_settle",
|
||||||
|
BizNo: order.OrderNo,
|
||||||
|
Remark: "订单结账释放模拟冻结金额",
|
||||||
|
},
|
||||||
|
wallet.Entry{
|
||||||
|
UserID: order.OwnerID,
|
||||||
|
OrderID: &orderID,
|
||||||
|
Direction: "in",
|
||||||
|
Amount: checkout.OwnerIncomeAmount,
|
||||||
|
BalanceType: "available",
|
||||||
|
BizType: "owner_income",
|
||||||
|
BizNo: order.OrderNo,
|
||||||
|
Remark: "订单结账收入",
|
||||||
|
},
|
||||||
|
wallet.Entry{
|
||||||
|
UserID: order.RenterID,
|
||||||
|
OrderID: &orderID,
|
||||||
|
Direction: "in",
|
||||||
|
Amount: checkout.RenterRefundAmount,
|
||||||
|
BalanceType: "available",
|
||||||
|
BizType: "deposit_release",
|
||||||
|
BizNo: order.OrderNo,
|
||||||
|
Remark: "订单结账退回押金",
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := notification.Append(tx,
|
||||||
|
notification.Entry{
|
||||||
|
UserID: order.RenterID,
|
||||||
|
Type: "settlement",
|
||||||
|
Title: "订单已完成",
|
||||||
|
Content: renterContent,
|
||||||
|
BizType: "order",
|
||||||
|
BizID: &orderID,
|
||||||
|
},
|
||||||
|
notification.Entry{
|
||||||
|
UserID: order.OwnerID,
|
||||||
|
Type: "settlement",
|
||||||
|
Title: "订单已完成",
|
||||||
|
Content: "订单已完成,结账金额已入账。",
|
||||||
|
BizType: "order",
|
||||||
|
BizID: &orderID,
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Save(order).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Save(checkout).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Save(&listing).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Save(&account).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCheckout(order model.RentalOrder, initiatedBy uint64, status string, content string, evidenceURLS []string, consumableAmount float64, coinConsumedM float64, otherAmount float64, explicitDeduct float64, useExplicitDeduct bool) (model.OrderCheckout, error) {
|
||||||
|
if consumableAmount < 0 || coinConsumedM < 0 || otherAmount < 0 || explicitDeduct < 0 {
|
||||||
|
return model.OrderCheckout{}, ErrInvalidCheckoutAmount
|
||||||
|
}
|
||||||
|
deductAmount := consumableAmount + otherAmount
|
||||||
|
if useExplicitDeduct {
|
||||||
|
deductAmount = explicitDeduct
|
||||||
|
}
|
||||||
|
if deductAmount > order.DepositAmount {
|
||||||
|
return model.OrderCheckout{}, ErrInvalidCheckoutAmount
|
||||||
|
}
|
||||||
|
renterRefund := order.DepositAmount - deductAmount
|
||||||
|
evidence, err := marshalStringList(evidenceURLS)
|
||||||
|
if err != nil {
|
||||||
|
return model.OrderCheckout{}, err
|
||||||
|
}
|
||||||
|
return model.OrderCheckout{
|
||||||
|
OrderID: order.ID,
|
||||||
|
InitiatedBy: initiatedBy,
|
||||||
|
Status: status,
|
||||||
|
RentAmount: order.RentAmount,
|
||||||
|
DepositAmount: order.DepositAmount,
|
||||||
|
ConsumableAmount: roundMoney(consumableAmount),
|
||||||
|
CoinConsumedM: roundMoney(coinConsumedM),
|
||||||
|
OtherAmount: roundMoney(otherAmount),
|
||||||
|
DepositDeductAmount: roundMoney(deductAmount),
|
||||||
|
RenterRefundAmount: roundMoney(renterRefund),
|
||||||
|
OwnerIncomeAmount: roundMoney(order.RentAmount + deductAmount),
|
||||||
|
Content: content,
|
||||||
|
EvidenceURLS: evidence,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalStringList(items []string) (datatypes.JSON, error) {
|
||||||
|
if items == nil {
|
||||||
|
items = []string{}
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(items)
|
||||||
|
return datatypes.JSON(raw), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeStringList(raw datatypes.JSON) []string {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
var items []string
|
||||||
|
if err := json.Unmarshal(raw, &items); err != nil {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func roundMoney(value float64) float64 {
|
||||||
|
return math.Round(value*100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasOpenCheckout(tx *gorm.DB, orderID uint64) (bool, error) {
|
||||||
|
var count int64
|
||||||
|
err := tx.Model(&model.OrderCheckout{}).
|
||||||
|
Where("order_id = ? AND status IN ?", orderID, []string{"submitted", "countered", "accepted", "disputed"}).
|
||||||
|
Count(&count).Error
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) latestCheckoutDTO(orderID uint64) *CheckoutDTO {
|
||||||
|
var checkout model.OrderCheckout
|
||||||
|
if err := r.db.Where("order_id = ?", orderID).Order("id DESC").First(&checkout).Error; err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dto := toCheckoutDTO(checkout)
|
||||||
|
return &dto
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) findCheckout(id uint64) (*model.OrderCheckout, error) {
|
||||||
|
var checkout model.OrderCheckout
|
||||||
|
if err := r.db.First(&checkout, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &checkout, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) findOrderAssetsForAdminUpdate(tx *gorm.DB, orderID uint64) (*model.RentalOrder, *model.RentalListing, *model.GameAccount, error) {
|
func (r *Repository) findOrderAssetsForAdminUpdate(tx *gorm.DB, orderID uint64) (*model.RentalOrder, *model.RentalListing, *model.GameAccount, error) {
|
||||||
var order model.RentalOrder
|
var order model.RentalOrder
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
||||||
@@ -687,6 +907,16 @@ func (r *Repository) findHandoffRecord(id uint64) (*HandoffRecordDTO, error) {
|
|||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) listItemBaseQuery() *gorm.DB {
|
||||||
|
return r.db.Table("rental_orders AS o").
|
||||||
|
Select("o.id, o.order_no, o.status, o.handoff_status, o.settlement_status, o.rent_amount, o.deposit_amount, o.created_at, "+
|
||||||
|
"a.title AS account_title, a.game_name, "+
|
||||||
|
"owner.nickname AS owner_nickname, renter.nickname AS renter_nickname").
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) baseQuery() *gorm.DB {
|
func (r *Repository) baseQuery() *gorm.DB {
|
||||||
return r.db.Table("rental_orders AS o").
|
return r.db.Table("rental_orders AS o").
|
||||||
Select("o.*, a.title, a.server_region, a.login_platform").
|
Select("o.*, a.title, a.server_region, a.login_platform").
|
||||||
@@ -737,6 +967,39 @@ func (row orderRow) toDTO() OrderDTO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type orderListRow struct {
|
||||||
|
ID uint64
|
||||||
|
OrderNo string
|
||||||
|
Status string
|
||||||
|
HandoffStatus string
|
||||||
|
SettlementStatus string
|
||||||
|
RentAmount float64
|
||||||
|
DepositAmount float64
|
||||||
|
CreatedAt time.Time
|
||||||
|
AccountTitle string
|
||||||
|
GameName string
|
||||||
|
OwnerNickname string
|
||||||
|
RenterNickname string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (row orderListRow) toListItemDTO() OrderListItemDTO {
|
||||||
|
return OrderListItemDTO{
|
||||||
|
ID: row.ID,
|
||||||
|
OrderNo: row.OrderNo,
|
||||||
|
ListingTitle: row.AccountTitle,
|
||||||
|
GameName: row.GameName,
|
||||||
|
AccountTitle: row.AccountTitle,
|
||||||
|
RenterNickname: row.RenterNickname,
|
||||||
|
OwnerNickname: row.OwnerNickname,
|
||||||
|
Status: row.Status,
|
||||||
|
HandoffStatus: row.HandoffStatus,
|
||||||
|
SettlementStatus: row.SettlementStatus,
|
||||||
|
RentAmount: row.RentAmount,
|
||||||
|
DepositAmount: row.DepositAmount,
|
||||||
|
CreatedAt: row.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func toHandoffDTO(record model.HandoffRecord) HandoffRecordDTO {
|
func toHandoffDTO(record model.HandoffRecord) HandoffRecordDTO {
|
||||||
return HandoffRecordDTO{
|
return HandoffRecordDTO{
|
||||||
ID: record.ID,
|
ID: record.ID,
|
||||||
@@ -751,6 +1014,31 @@ func toHandoffDTO(record model.HandoffRecord) HandoffRecordDTO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toCheckoutDTO(checkout model.OrderCheckout) CheckoutDTO {
|
||||||
|
return CheckoutDTO{
|
||||||
|
ID: checkout.ID,
|
||||||
|
OrderID: checkout.OrderID,
|
||||||
|
InitiatedBy: checkout.InitiatedBy,
|
||||||
|
Status: checkout.Status,
|
||||||
|
RentAmount: checkout.RentAmount,
|
||||||
|
DepositAmount: checkout.DepositAmount,
|
||||||
|
ConsumableAmount: checkout.ConsumableAmount,
|
||||||
|
CoinConsumedM: checkout.CoinConsumedM,
|
||||||
|
OtherAmount: checkout.OtherAmount,
|
||||||
|
DepositDeductAmount: checkout.DepositDeductAmount,
|
||||||
|
RenterRefundAmount: checkout.RenterRefundAmount,
|
||||||
|
OwnerIncomeAmount: checkout.OwnerIncomeAmount,
|
||||||
|
Content: checkout.Content,
|
||||||
|
EvidenceURLS: decodeStringList(checkout.EvidenceURLS),
|
||||||
|
OwnerAdjustmentReason: checkout.OwnerAdjustmentReason,
|
||||||
|
OwnerAdjustedAt: checkout.OwnerAdjustedAt,
|
||||||
|
RenterConfirmedAt: checkout.RenterConfirmedAt,
|
||||||
|
RenterRejectedAt: checkout.RenterRejectedAt,
|
||||||
|
CreatedAt: checkout.CreatedAt,
|
||||||
|
UpdatedAt: checkout.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func makeAccountSnapshot(account model.GameAccount) (datatypes.JSON, error) {
|
func makeAccountSnapshot(account model.GameAccount) (datatypes.JSON, error) {
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
"account_id": account.ID,
|
"account_id": account.ID,
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ var (
|
|||||||
ErrOrderCannotCancel = errors.New("order cannot cancel")
|
ErrOrderCannotCancel = errors.New("order cannot cancel")
|
||||||
ErrOrderCannotHandoff = errors.New("order cannot handoff")
|
ErrOrderCannotHandoff = errors.New("order cannot handoff")
|
||||||
ErrOrderCannotReceive = errors.New("order cannot receive")
|
ErrOrderCannotReceive = errors.New("order cannot receive")
|
||||||
ErrOrderCannotReturn = errors.New("order cannot return")
|
|
||||||
ErrOrderCannotComplete = errors.New("order cannot complete")
|
ErrOrderCannotComplete = errors.New("order cannot complete")
|
||||||
|
ErrCheckoutCannotSubmit = errors.New("checkout cannot submit")
|
||||||
|
ErrCheckoutCannotConfirm = errors.New("checkout cannot confirm")
|
||||||
|
ErrCheckoutCannotCounter = errors.New("checkout cannot counter")
|
||||||
|
ErrInvalidCheckoutAmount = errors.New("invalid checkout amount")
|
||||||
ErrPermissionDenied = errors.New("permission denied")
|
ErrPermissionDenied = errors.New("permission denied")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,35 +69,52 @@ func (s *Service) HandoffRecords(userID uint64, orderID uint64) ([]HandoffRecord
|
|||||||
return s.repo.HandoffRecords(userID, orderID)
|
return s.repo.HandoffRecords(userID, orderID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) SubmitReturn(userID uint64, orderID uint64, req SubmitReturnRequest) (*HandoffRecordDTO, error) {
|
func (s *Service) SubmitCheckout(userID uint64, orderID uint64, req SubmitCheckoutRequest) (*HandoffRecordDTO, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
if orderID == 0 || req.Content == "" {
|
if orderID == 0 || req.Content == "" {
|
||||||
return nil, ErrOrderCannotReturn
|
return nil, ErrCheckoutCannotSubmit
|
||||||
}
|
}
|
||||||
return s.repo.SubmitReturn(userID, orderID, req)
|
return s.repo.SubmitCheckout(userID, orderID, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ConfirmReturn(userID uint64, orderID uint64) error {
|
func (s *Service) ConfirmCheckout(userID uint64, orderID uint64) error {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return ErrDependencyUnavailable
|
return ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
return s.repo.ConfirmReturn(userID, orderID)
|
return s.repo.ConfirmCheckout(userID, orderID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ListForUser(userID uint64) ([]OrderDTO, error) {
|
func (s *Service) CounterCheckout(userID uint64, orderID uint64, req CounterCheckoutRequest) (*CheckoutDTO, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
return s.repo.ListForUser(userID)
|
if orderID == 0 || req.Reason == "" {
|
||||||
|
return nil, ErrCheckoutCannotCounter
|
||||||
|
}
|
||||||
|
return s.repo.CounterCheckout(userID, orderID, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ListAdmin() ([]OrderDTO, error) {
|
func (s *Service) AcceptCheckout(userID uint64, orderID uint64) error {
|
||||||
|
if s.repo == nil {
|
||||||
|
return ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
return s.repo.AcceptCheckout(userID, orderID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListForUser(userID uint64, page int, pageSize int) (*PaginatedOrders, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
return s.repo.ListAdmin()
|
return s.repo.ListForUser(userID, page, pageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListAdmin(page int, pageSize int) (*PaginatedOrders, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
return s.repo.ListAdmin(page, pageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) FindAdmin(orderID uint64) (*OrderDTO, error) {
|
func (s *Service) FindAdmin(orderID uint64) (*OrderDTO, error) {
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ type defaultConfig struct {
|
|||||||
var defaultConfigs = []defaultConfig{
|
var defaultConfigs = []defaultConfig{
|
||||||
{Key: "handoff.owner_submit_timeout_minutes", Value: "30", Description: "号主待交接超时分钟数"},
|
{Key: "handoff.owner_submit_timeout_minutes", Value: "30", Description: "号主待交接超时分钟数"},
|
||||||
{Key: "handoff.renter_confirm_timeout_minutes", Value: "30", Description: "租客待确认收号超时分钟数"},
|
{Key: "handoff.renter_confirm_timeout_minutes", Value: "30", Description: "租客待确认收号超时分钟数"},
|
||||||
{Key: "handoff.owner_return_confirm_timeout_minutes", Value: "120", Description: "号主待确认归还超时分钟数"},
|
{Key: "handoff.owner_return_confirm_timeout_minutes", Value: "120", Description: "号主待确认结账超时分钟数"},
|
||||||
{Key: "order.return_overdue_grace_minutes", Value: "10", Description: "预计截止后归还宽限分钟数"},
|
{Key: "order.return_overdue_grace_minutes", Value: "10", Description: "预计截止后结账宽限分钟数"},
|
||||||
{Key: "deposit.min_amount", Value: "50", Description: "发布租号最低押金"},
|
{Key: "deposit.min_amount", Value: "50", Description: "发布租号最低押金"},
|
||||||
{Key: "listing.review_required", Value: "false", Description: "发布账号是否需要后台人工审核"},
|
{Key: "listing.review_required", Value: "false", Description: "发布账号是否需要后台人工审核"},
|
||||||
{Key: "risk.sms_limit_per_phone_hour", Value: "5", Description: "单手机号每小时短信验证码次数"},
|
{Key: "risk.sms_limit_per_phone_hour", Value: "5", Description: "单手机号每小时短信验证码次数"},
|
||||||
|
|||||||
@@ -25,10 +25,18 @@ type LedgerDTO struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AdminLedgerQuery struct {
|
type AdminLedgerQuery struct {
|
||||||
UserID uint64
|
UserID uint64
|
||||||
OrderID uint64
|
OrderID uint64
|
||||||
BizType string
|
BizType string
|
||||||
Limit int
|
Page int
|
||||||
|
PageSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginatedResult struct {
|
||||||
|
Items interface{} `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminLedgerDTO struct {
|
type AdminLedgerDTO struct {
|
||||||
|
|||||||
@@ -32,18 +32,34 @@ func (h *Handler) Balance(c *gin.Context) {
|
|||||||
response.OK(c, account)
|
response.OK(c, account)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parsePagination(c *gin.Context) (int, int) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
if pageSize > 100 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
return page, pageSize
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) Ledger(c *gin.Context) {
|
func (h *Handler) Ledger(c *gin.Context) {
|
||||||
userID, ok := currentUserID(c)
|
userID, ok := currentUserID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
response.Unauthorized(c, "缺少用户上下文")
|
response.Unauthorized(c, "缺少用户上下文")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
items, err := h.service.Ledger(userID)
|
page, pageSize := parsePagination(c)
|
||||||
|
result, err := h.service.Ledger(userID, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeWalletError(c, err)
|
writeWalletError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.OK(c, gin.H{"items": items})
|
response.OK(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) AdminLedger(c *gin.Context) {
|
func (h *Handler) AdminLedger(c *gin.Context) {
|
||||||
@@ -51,12 +67,12 @@ func (h *Handler) AdminLedger(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
items, err := h.service.AdminLedger(query)
|
result, err := h.service.AdminLedger(query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeWalletError(c, err)
|
writeWalletError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.OK(c, gin.H{"items": items})
|
response.OK(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseAdminLedgerQuery(c *gin.Context) (AdminLedgerQuery, bool) {
|
func parseAdminLedgerQuery(c *gin.Context) (AdminLedgerQuery, bool) {
|
||||||
@@ -77,15 +93,10 @@ func parseAdminLedgerQuery(c *gin.Context) (AdminLedgerQuery, bool) {
|
|||||||
}
|
}
|
||||||
query.OrderID = value
|
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")
|
query.BizType = c.Query("biz_type")
|
||||||
|
page, pageSize := parsePagination(c)
|
||||||
|
query.Page = page
|
||||||
|
query.PageSize = pageSize
|
||||||
return query, true
|
return query, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,24 +45,24 @@ func (r *Repository) Account(userID uint64) (*AccountDTO, error) {
|
|||||||
return toAccountDTO(account), nil
|
return toAccountDTO(account), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) Ledger(userID uint64) ([]LedgerDTO, error) {
|
func (r *Repository) Ledger(userID uint64, page, pageSize int) (*PaginatedResult, error) {
|
||||||
|
var total int64
|
||||||
|
if err := r.db.Model(&model.WalletLedger{}).Where("user_id = ?", userID).Count(&total).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
var rows []model.WalletLedger
|
var rows []model.WalletLedger
|
||||||
if err := r.db.Where("user_id = ?", userID).Order("id DESC").Limit(100).Find(&rows).Error; err != nil {
|
if err := r.db.Where("user_id = ?", userID).Order("id DESC").Offset(offset).Limit(pageSize).Find(&rows).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items := make([]LedgerDTO, 0, len(rows))
|
items := make([]LedgerDTO, 0, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
items = append(items, toLedgerDTO(row))
|
items = append(items, toLedgerDTO(row))
|
||||||
}
|
}
|
||||||
return items, nil
|
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) AdminLedger(query AdminLedgerQuery) ([]AdminLedgerDTO, error) {
|
func (r *Repository) AdminLedger(query AdminLedgerQuery) (*PaginatedResult, error) {
|
||||||
limit := query.Limit
|
|
||||||
if limit <= 0 || limit > 500 {
|
|
||||||
limit = 200
|
|
||||||
}
|
|
||||||
|
|
||||||
db := r.db.Table("wallet_ledger AS wl").
|
db := r.db.Table("wallet_ledger AS wl").
|
||||||
Select(`wl.id, wl.ledger_no, wl.user_id, COALESCE(u.phone, '') AS user_phone,
|
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,
|
COALESCE(u.nickname, '') AS user_nickname, wl.order_id, COALESCE(ro.order_no, '') AS order_no,
|
||||||
@@ -71,21 +71,31 @@ func (r *Repository) AdminLedger(query AdminLedgerQuery) ([]AdminLedgerDTO, erro
|
|||||||
Joins("LEFT JOIN users AS u ON u.id = wl.user_id").
|
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")
|
Joins("LEFT JOIN rental_orders AS ro ON ro.id = wl.order_id")
|
||||||
|
|
||||||
|
countDB := r.db.Model(&model.WalletLedger{})
|
||||||
if query.UserID > 0 {
|
if query.UserID > 0 {
|
||||||
db = db.Where("wl.user_id = ?", query.UserID)
|
db = db.Where("wl.user_id = ?", query.UserID)
|
||||||
|
countDB = countDB.Where("user_id = ?", query.UserID)
|
||||||
}
|
}
|
||||||
if query.OrderID > 0 {
|
if query.OrderID > 0 {
|
||||||
db = db.Where("wl.order_id = ?", query.OrderID)
|
db = db.Where("wl.order_id = ?", query.OrderID)
|
||||||
|
countDB = countDB.Where("order_id = ?", query.OrderID)
|
||||||
}
|
}
|
||||||
if query.BizType != "" {
|
if query.BizType != "" {
|
||||||
db = db.Where("wl.biz_type = ?", query.BizType)
|
db = db.Where("wl.biz_type = ?", query.BizType)
|
||||||
|
countDB = countDB.Where("biz_type = ?", query.BizType)
|
||||||
}
|
}
|
||||||
|
|
||||||
var items []AdminLedgerDTO
|
var total int64
|
||||||
if err := db.Order("wl.id DESC").Limit(limit).Scan(&items).Error; err != nil {
|
if err := countDB.Count(&total).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return items, nil
|
|
||||||
|
offset := (query.Page - 1) * query.PageSize
|
||||||
|
var items []AdminLedgerDTO
|
||||||
|
if err := db.Order("wl.id DESC").Offset(offset).Limit(query.PageSize).Scan(&items).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &PaginatedResult{Items: items, Total: total, Page: query.Page, PageSize: query.PageSize}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func AppendEntries(tx *gorm.DB, entries ...Entry) error {
|
func AppendEntries(tx *gorm.DB, entries ...Entry) error {
|
||||||
|
|||||||
@@ -19,14 +19,14 @@ func (s *Service) Account(userID uint64) (*AccountDTO, error) {
|
|||||||
return s.repo.Account(userID)
|
return s.repo.Account(userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Ledger(userID uint64) ([]LedgerDTO, error) {
|
func (s *Service) Ledger(userID uint64, page, pageSize int) (*PaginatedResult, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
return s.repo.Ledger(userID)
|
return s.repo.Ledger(userID, page, pageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) AdminLedger(query AdminLedgerQuery) ([]AdminLedgerDTO, error) {
|
func (s *Service) AdminLedger(query AdminLedgerQuery) (*PaginatedResult, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
if deps.DB != nil {
|
if deps.DB != nil {
|
||||||
adminAuthRepo = adminauth.NewRepository(deps.DB, deps.Redis, jwtManager)
|
adminAuthRepo = adminauth.NewRepository(deps.DB, deps.Redis, jwtManager)
|
||||||
}
|
}
|
||||||
adminAuthService := adminauth.NewService(adminAuthRepo)
|
adminAuthService := adminauth.NewService(adminAuthRepo, jwtManager)
|
||||||
adminAuthHandler := adminauth.NewHandler(adminAuthService)
|
adminAuthHandler := adminauth.NewHandler(adminAuthService)
|
||||||
var adminDashboardRepo *admindashboard.Repository
|
var adminDashboardRepo *admindashboard.Repository
|
||||||
if deps.DB != nil {
|
if deps.DB != nil {
|
||||||
@@ -170,8 +170,10 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
orderRoutes.POST("/:id/handoff", orderHandler.SubmitHandoff)
|
orderRoutes.POST("/:id/handoff", orderHandler.SubmitHandoff)
|
||||||
orderRoutes.GET("/:id/handoff-records", orderHandler.HandoffRecords)
|
orderRoutes.GET("/:id/handoff-records", orderHandler.HandoffRecords)
|
||||||
orderRoutes.POST("/:id/confirm-receive", orderHandler.ConfirmReceive)
|
orderRoutes.POST("/:id/confirm-receive", orderHandler.ConfirmReceive)
|
||||||
orderRoutes.POST("/:id/return", orderHandler.SubmitReturn)
|
orderRoutes.POST("/:id/checkout", orderHandler.SubmitCheckout)
|
||||||
orderRoutes.POST("/:id/confirm-return", orderHandler.ConfirmReturn)
|
orderRoutes.POST("/:id/checkout/confirm", orderHandler.ConfirmCheckout)
|
||||||
|
orderRoutes.POST("/:id/checkout/counter", orderHandler.CounterCheckout)
|
||||||
|
orderRoutes.POST("/:id/checkout/accept", orderHandler.AcceptCheckout)
|
||||||
orderRoutes.POST("/:id/dispute", disputeHandler.Create)
|
orderRoutes.POST("/:id/dispute", disputeHandler.Create)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +211,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
{
|
{
|
||||||
adminAuthRoutes.GET("/captcha", adminAuthHandler.Captcha)
|
adminAuthRoutes.GET("/captcha", adminAuthHandler.Captcha)
|
||||||
adminAuthRoutes.POST("/login", adminAuthHandler.Login)
|
adminAuthRoutes.POST("/login", adminAuthHandler.Login)
|
||||||
|
adminAuthRoutes.POST("/refresh", adminAuthHandler.Refresh)
|
||||||
}
|
}
|
||||||
|
|
||||||
adminRoutes := api.Group("/admin", requireAdmin)
|
adminRoutes := api.Group("/admin", requireAdmin)
|
||||||
|
|||||||
@@ -109,6 +109,31 @@ CREATE TABLE handoff_records (
|
|||||||
KEY idx_handoff_records_order_id (order_id)
|
KEY idx_handoff_records_order_id (order_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE order_checkouts (
|
||||||
|
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
order_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
initiated_by BIGINT UNSIGNED NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'submitted',
|
||||||
|
rent_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
deposit_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
consumable_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
coin_consumed_m DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
other_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
deposit_deduct_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
renter_refund_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
owner_income_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
content TEXT NULL,
|
||||||
|
evidence_urls JSON NULL,
|
||||||
|
owner_adjustment_reason TEXT NULL,
|
||||||
|
owner_adjusted_at DATETIME NULL,
|
||||||
|
renter_confirmed_at DATETIME NULL,
|
||||||
|
renter_rejected_at DATETIME NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_order_checkouts_order_id (order_id),
|
||||||
|
KEY idx_order_checkouts_status (status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
CREATE TABLE wallet_accounts (
|
CREATE TABLE wallet_accounts (
|
||||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||||
user_id BIGINT UNSIGNED NOT NULL,
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS order_checkouts (
|
||||||
|
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
order_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
initiated_by BIGINT UNSIGNED NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'submitted',
|
||||||
|
rent_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
deposit_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
consumable_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
coin_consumed_m DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
other_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
deposit_deduct_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
renter_refund_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
owner_income_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
content TEXT NULL,
|
||||||
|
evidence_urls JSON NULL,
|
||||||
|
owner_adjustment_reason TEXT NULL,
|
||||||
|
owner_adjusted_at DATETIME NULL,
|
||||||
|
renter_confirmed_at DATETIME NULL,
|
||||||
|
renter_rejected_at DATETIME NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_order_checkouts_order_id (order_id),
|
||||||
|
KEY idx_order_checkouts_status (status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- 000003_add_indexes.sql
|
||||||
|
-- 添加缺失的业务索引以优化查询性能
|
||||||
|
|
||||||
|
-- rental_orders 索引(号主/租客查订单、状态筛选、超时扫描)
|
||||||
|
CREATE INDEX idx_rental_orders_owner_id ON rental_orders(owner_id);
|
||||||
|
CREATE INDEX idx_rental_orders_renter_id ON rental_orders(renter_id);
|
||||||
|
CREATE INDEX idx_rental_orders_status ON rental_orders(status);
|
||||||
|
CREATE INDEX idx_rental_orders_status_updated ON rental_orders(status, updated_at);
|
||||||
|
CREATE INDEX idx_rental_orders_settlement_status ON rental_orders(settlement_status);
|
||||||
|
|
||||||
|
-- handoff_records 索引
|
||||||
|
CREATE INDEX idx_handoff_records_order_id ON handoff_records(order_id);
|
||||||
|
|
||||||
|
-- notifications 索引
|
||||||
|
CREATE INDEX idx_notifications_user_id ON notifications(user_id);
|
||||||
|
|
||||||
|
-- wallet_entries 索引
|
||||||
|
CREATE INDEX idx_wallet_entries_user_id ON wallet_entries(user_id);
|
||||||
|
CREATE INDEX idx_wallet_entries_order_id ON wallet_entries(order_id);
|
||||||
|
|
||||||
|
-- disputes 索引
|
||||||
|
CREATE INDEX idx_disputes_order_id ON disputes(order_id);
|
||||||
|
CREATE INDEX idx_disputes_status ON disputes(status);
|
||||||
|
|
||||||
|
-- audit_logs 索引
|
||||||
|
CREATE INDEX idx_audit_logs_biz_type_biz_id ON audit_logs(biz_type, biz_id);
|
||||||
|
CREATE INDEX idx_audit_logs_actor_id ON audit_logs(actor_id);
|
||||||
|
|
||||||
|
-- rental_listings 补充索引
|
||||||
|
CREATE INDEX idx_rental_listings_owner_id ON rental_listings(owner_id);
|
||||||
|
CREATE INDEX idx_rental_listings_status ON rental_listings(status);
|
||||||
|
CREATE INDEX idx_rental_listings_game_name ON rental_listings(game_name);
|
||||||
|
|
||||||
|
-- order_checkouts 索引补充
|
||||||
|
CREATE INDEX idx_order_checkouts_order_id_status ON order_checkouts(order_id, status);
|
||||||
+5
-3
@@ -7,7 +7,7 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。
|
|||||||
- 认证:短信验证码、手机号登录、Token 刷新、退出登录。
|
- 认证:短信验证码、手机号登录、Token 刷新、退出登录。
|
||||||
- 实名:发起认证、查询状态、服务回调。
|
- 实名:发起认证、查询状态、服务回调。
|
||||||
- 发布:创建、修改、提交审核、下架、列表、详情。
|
- 发布:创建、修改、提交审核、下架、列表、详情。
|
||||||
- 订单:创建、取消、确认收号、提交归还、确认归还、发起申诉。
|
- 订单:创建、取消、确认收号、发起结账、确认结账、修改结账、确认/拒绝修正、发起申诉。
|
||||||
- 交接:提交交接说明、查看交接记录。
|
- 交接:提交交接说明、查看交接记录。
|
||||||
- 钱包:余额、流水、提现预留。
|
- 钱包:余额、流水、提现预留。
|
||||||
- 通知:站内信列表、标记已读。
|
- 通知:站内信列表、标记已读。
|
||||||
@@ -37,8 +37,10 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。
|
|||||||
- `POST /api/orders/{id}/handoff`
|
- `POST /api/orders/{id}/handoff`
|
||||||
- `GET /api/orders/{id}/handoff-records`
|
- `GET /api/orders/{id}/handoff-records`
|
||||||
- `POST /api/orders/{id}/confirm-receive`
|
- `POST /api/orders/{id}/confirm-receive`
|
||||||
- `POST /api/orders/{id}/return`
|
- `POST /api/orders/{id}/checkout`
|
||||||
- `POST /api/orders/{id}/confirm-return`
|
- `POST /api/orders/{id}/checkout/confirm`
|
||||||
|
- `POST /api/orders/{id}/checkout/counter`
|
||||||
|
- `POST /api/orders/{id}/checkout/accept`
|
||||||
- `POST /api/orders/{id}/dispute`
|
- `POST /api/orders/{id}/dispute`
|
||||||
- `GET /api/disputes`
|
- `GET /api/disputes`
|
||||||
- `GET /api/disputes/{id}`
|
- `GET /api/disputes/{id}`
|
||||||
|
|||||||
+18
-13
@@ -49,24 +49,27 @@
|
|||||||
- 确认收号时会重新计算租赁开始时间和结束时间。
|
- 确认收号时会重新计算租赁开始时间和结束时间。
|
||||||
- 交接记录保存在 `handoff_records`,订单双方都可以查看。
|
- 交接记录保存在 `handoff_records`,订单双方都可以查看。
|
||||||
|
|
||||||
## 开发态归还与完成
|
## 开发态结账与完成
|
||||||
|
|
||||||
- 租赁中订单可以由租客提交归还。
|
- 租赁中订单可以由租客发起结账,填写使用结束说明、消耗金额、哈夫币消耗量、其他扣款和证据链接。
|
||||||
- 逾期中订单仍允许租客提交归还,但后台和号主可以根据逾期情况发起申诉或客服处理。
|
- 逾期中订单仍允许租客发起结账,但后台和号主可以根据逾期情况发起申诉或客服处理。
|
||||||
- 租客提交归还后,订单状态变为 `pending_return_confirm`,交接状态变为 `pending_owner_return_confirm`。
|
- 租客发起结账后,订单状态变为 `pending_checkout_confirm`,交接状态变为 `pending_owner_checkout`。
|
||||||
- 只有号主可以确认归还。
|
- 只有号主可以确认结账或修改结账。
|
||||||
- 号主确认归还后,订单状态变为 `completed`,交接状态变为 `returned`,结算状态先标记为 `settled`。
|
- 号主直接确认结账后,订单状态变为 `completed`,交接状态变为 `returned`,结算状态标记为 `settled`。
|
||||||
|
- 号主修改结账后,订单状态变为 `pending_checkout_accept`,交接状态变为 `pending_renter_checkout`,必须等待租客确认修正。
|
||||||
|
- 待号主确认结账或待租客确认修正时,任一方都可以发起结账争议。
|
||||||
|
- 结账争议后订单状态变为 `checkout_disputing`,交接状态变为 `checkout_disputed`,由客服仲裁。
|
||||||
- 订单完成后,账号和发布状态恢复为 `published`。
|
- 订单完成后,账号和发布状态恢复为 `published`。
|
||||||
- 当前会生成开发态模拟钱包流水,不代表真实支付或提现。
|
- 当前会生成开发态模拟钱包流水,不代表真实支付或提现。
|
||||||
|
|
||||||
## 开发态超时任务
|
## 开发态超时任务
|
||||||
|
|
||||||
- 后端启动后会运行订单超时扫描任务,默认每 1 分钟执行一次。
|
- 后端启动后会运行订单超时扫描任务,默认每 1 分钟执行一次。
|
||||||
- 任务从 `system_configs` 读取超时阈值,包括号主待交接、租客确认收号、租客归还宽限和号主确认归还。
|
- 任务从 `system_configs` 读取超时阈值,包括号主待交接、租客确认收号、租客结账宽限和号主确认结账。
|
||||||
- 号主待交接超时:订单保持 `pending_handoff`,交接状态变为 `owner_timeout`,租客仍可取消订单或发起申诉。
|
- 号主待交接超时:订单保持 `pending_handoff`,交接状态变为 `owner_timeout`,租客仍可取消订单或发起申诉。
|
||||||
- 租客确认收号超时:订单状态变为 `abnormal`,交接状态变为 `renter_confirm_timeout`,进入客服介入。
|
- 租客确认收号超时:订单状态变为 `abnormal`,交接状态变为 `renter_confirm_timeout`,进入客服介入。
|
||||||
- 租客逾期未归还:订单状态变为 `overdue`,交接状态变为 `return_overdue`,租客仍可提交归还,号主可发起申诉。
|
- 租客逾期未结账:订单状态变为 `overdue`,交接状态变为 `return_overdue`,租客仍可发起结账,号主可发起申诉。
|
||||||
- 号主确认归还超时:订单状态变为 `abnormal`,交接状态变为 `owner_return_confirm_timeout`,进入客服复核。
|
- 号主确认结账超时:订单状态变为 `abnormal`,交接状态变为 `owner_checkout_confirm_timeout`,进入客服复核。
|
||||||
- 每个超时动作只推进一次状态,避免重复通知。
|
- 每个超时动作只推进一次状态,避免重复通知。
|
||||||
- 超时动作会给相关用户写入站内信,并以 `system` 身份写入 `audit_logs`。
|
- 超时动作会给相关用户写入站内信,并以 `system` 身份写入 `audit_logs`。
|
||||||
|
|
||||||
@@ -97,8 +100,10 @@
|
|||||||
- 租客取消订单后通知号主和租客。
|
- 租客取消订单后通知号主和租客。
|
||||||
- 号主提交交接说明后通知租客。
|
- 号主提交交接说明后通知租客。
|
||||||
- 租客确认收号后通知号主。
|
- 租客确认收号后通知号主。
|
||||||
- 租客提交归还后通知号主。
|
- 租客发起结账后通知号主。
|
||||||
- 号主确认归还后通知号主和租客。
|
- 号主确认结账后通知号主和租客。
|
||||||
|
- 号主修改结账后通知租客。
|
||||||
|
- 租客拒绝修正结账后通知双方并进入争议。
|
||||||
- 发起申诉后通知对方和发起人。
|
- 发起申诉后通知对方和发起人。
|
||||||
- 仲裁完成后通知号主和租客。
|
- 仲裁完成后通知号主和租客。
|
||||||
- 站内信支持列表查询和标记已读。
|
- 站内信支持列表查询和标记已读。
|
||||||
@@ -107,7 +112,7 @@
|
|||||||
|
|
||||||
- 订单双方都可以在非终态订单发起申诉。
|
- 订单双方都可以在非终态订单发起申诉。
|
||||||
- 同一个订单同一时间只允许存在一个 `open` 或 `processing` 申诉。
|
- 同一个订单同一时间只允许存在一个 `open` 或 `processing` 申诉。
|
||||||
- 发起申诉后订单状态变为 `disputing`,账号和发布保持锁定。
|
- 发起普通申诉后订单状态变为 `disputing`,发起结账争议后订单状态变为 `checkout_disputing`,账号和发布保持锁定。
|
||||||
- 开发态后台仲裁接口为 `/api/admin/disputes`,当前只要求登录,后续接后台管理员和 RBAC。
|
- 开发态后台仲裁接口为 `/api/admin/disputes`,当前只要求登录,后续接后台管理员和 RBAC。
|
||||||
- 仲裁会释放开发态模拟冻结金额,并根据结果生成钱包流水。
|
- 仲裁会释放开发态模拟冻结金额,并根据结果生成钱包流水。
|
||||||
- 全额退款:释放冻结金额后,将租金加押金作为可用余额退给租客,订单置为 `closed`。
|
- 全额退款:释放冻结金额后,将租金加押金作为可用余额退给租客,订单置为 `closed`。
|
||||||
@@ -139,7 +144,7 @@
|
|||||||
|
|
||||||
- 后台仪表盘接口为 `/api/admin/dashboard`,前端页面为 `/admin/dashboard`。
|
- 后台仪表盘接口为 `/api/admin/dashboard`,前端页面为 `/admin/dashboard`。
|
||||||
- 当前统计用户数、实名用户数、商品数、上架数、订单数、租赁中订单、今日订单、今日钱包流水。
|
- 当前统计用户数、实名用户数、商品数、上架数、订单数、租赁中订单、今日订单、今日钱包流水。
|
||||||
- 待处理事项包括待审核商品、待仲裁申诉、待交接订单和待归还确认订单。
|
- 待处理事项包括待审核商品、待仲裁申诉、待交接订单和待结账确认订单。
|
||||||
- 最近订单和最近申诉用于运营快速定位问题,后续接入后台订单管理和商品审核后再跳转到对应详情页。
|
- 最近订单和最近申诉用于运营快速定位问题,后续接入后台订单管理和商品审核后再跳转到对应详情页。
|
||||||
|
|
||||||
## 开发态用户管理
|
## 开发态用户管理
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# 订单支付确认改造 —— 实施计划
|
||||||
|
|
||||||
|
> 状态:待执行
|
||||||
|
> 日期:2026-05-24
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
将当前"下单即锁定"的流程改造为:
|
||||||
|
|
||||||
|
```
|
||||||
|
下单 → 待支付(pending_payment) → 支付确认 → 冻结锁定 → 待交接(pending_handoff)
|
||||||
|
```
|
||||||
|
|
||||||
|
同时清理 `price_hourly`、`price_daily`、`price_weekly`、`rent_start_at`、`rent_end_at`、`rent_hours` 等时租残留字段。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 改造清单(按依赖顺序)
|
||||||
|
|
||||||
|
```
|
||||||
|
第1层(无依赖,可并行)
|
||||||
|
├── A. 合并 3 个 SQL 迁移文件 → 1 个 000001_init.sql
|
||||||
|
│ 修改 rental_listings:price + in_transaction
|
||||||
|
│ 修改 rental_orders:rented_at + estimated_duration_hours
|
||||||
|
│ 修改 notifications:category/link_type/link_id/extra_data
|
||||||
|
│ 新增 sms_logs 表
|
||||||
|
│ 同步改 scripts/dev.sh
|
||||||
|
│
|
||||||
|
├── B. Go 模型改造
|
||||||
|
│ model/listing.go → Price + InTransaction
|
||||||
|
│ model/order.go → RentedAt + EstimatedDurationHours
|
||||||
|
│ model/notification.go → Category/LinkType/LinkID/ExtraData
|
||||||
|
│
|
||||||
|
第2层(依赖第1层)
|
||||||
|
├── C. Listing 模块清理
|
||||||
|
│ listing/dto.go → ListingDTO + CreateRequest 适配
|
||||||
|
│ listing/repository.go → Create/Update/listQuery/toDTO 全部替换
|
||||||
|
│ listing/service.go → 校验适配
|
||||||
|
│
|
||||||
|
├── D. Order DTO 改造
|
||||||
|
│ order/dto.go → 删 RentStartAt/RentEndAt/RentHours,加 RentedAt/EstimatedDurationHours
|
||||||
|
│
|
||||||
|
第3层(依赖第2层)
|
||||||
|
├── E. Order 核心改造
|
||||||
|
│ order/service.go → 删 internalOrderHours,加错误,加 Pay()
|
||||||
|
│ order/repository.go → Create() → pending_payment + in_transaction
|
||||||
|
│ order/repository.go → 新增 Pay():余额→扣款→冻结→锁定→通知
|
||||||
|
│ order/repository.go → Cancel() 适配 pending_payment
|
||||||
|
│ order/handler.go → 新增 Pay handler
|
||||||
|
│ 路由注册 → POST /orders/:id/pay
|
||||||
|
│
|
||||||
|
├── F. 钱包充值
|
||||||
|
│ wallet/repository.go → Recharge()
|
||||||
|
│ wallet/service.go → Recharge()
|
||||||
|
│ wallet/handler.go → Recharge handler
|
||||||
|
│ 路由注册 → POST /wallet/recharge
|
||||||
|
│
|
||||||
|
第4层(依赖第3层 E)
|
||||||
|
└── G. 超时扫描
|
||||||
|
job.go → 新增 handlePendingPaymentTimeout(15min 超时取消)
|
||||||
|
system_configs → order.pending_payment_timeout_minutes: 15
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API 变更
|
||||||
|
|
||||||
|
| API | 类型 | 说明 |
|
||||||
|
|-----|------|------|
|
||||||
|
| `POST /api/orders` | 行为变更 | 返回 `pending_payment`,不锁商品,标记 `in_transaction` |
|
||||||
|
| `POST /api/orders/{id}/pay` | **新增** | 校验余额→扣款→冻结→锁定→通知 |
|
||||||
|
| `POST /api/orders/{id}/cancel` | 行为变更 | 允许取消 `pending_payment` |
|
||||||
|
| `POST /api/wallet/recharge` | **新增** | 开发环境充值 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
### 修改(17 个)
|
||||||
|
```
|
||||||
|
backend/migrations/000001_init.sql
|
||||||
|
scripts/dev.sh
|
||||||
|
backend/internal/model/listing.go
|
||||||
|
backend/internal/model/order.go
|
||||||
|
backend/internal/model/notification.go
|
||||||
|
backend/internal/modules/listing/dto.go
|
||||||
|
backend/internal/modules/listing/repository.go
|
||||||
|
backend/internal/modules/listing/service.go
|
||||||
|
backend/internal/modules/order/dto.go
|
||||||
|
backend/internal/modules/order/service.go
|
||||||
|
backend/internal/modules/order/repository.go
|
||||||
|
backend/internal/modules/order/handler.go
|
||||||
|
backend/internal/modules/wallet/repository.go
|
||||||
|
backend/internal/modules/wallet/service.go
|
||||||
|
backend/internal/modules/wallet/handler.go
|
||||||
|
backend/internal/router/router.go(或同等路由文件)
|
||||||
|
backend/internal/jobs/ordertimeout/job.go
|
||||||
|
backend/internal/modules/systemconfig/defaults.go(或同等文件)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 删除(2 个)
|
||||||
|
```
|
||||||
|
backend/migrations/000002_order_checkouts.sql
|
||||||
|
backend/migrations/000003_add_indexes.sql
|
||||||
|
```
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
# HFB Sys 订单购买测试 & 消息系统增强 —— 综合分析报告
|
||||||
|
|
||||||
|
> 生成日期:2026-05-24
|
||||||
|
> 状态:待实施
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第一部分:如何进行实际的订单购买测试
|
||||||
|
|
||||||
|
### 1. 当前完整订单购买链路
|
||||||
|
|
||||||
|
从前端到后端,当前链路如下:
|
||||||
|
|
||||||
|
```
|
||||||
|
租客浏览商品列表 → 点击商品详情 → 点击"立即租赁"
|
||||||
|
↓
|
||||||
|
MobileListingDetailView.vue 调用 createOrder(listingId)
|
||||||
|
↓
|
||||||
|
POST /api/orders {listing_id}
|
||||||
|
↓
|
||||||
|
handler.CreateOrder() → 验证 listing 状态(published & approved)
|
||||||
|
↓
|
||||||
|
service.CreateOrder(ctx, userID, listingID)
|
||||||
|
↓
|
||||||
|
repository.Create(tx) → 一条大事务:
|
||||||
|
├── SELECT listing + account FOR UPDATE (悲观锁防并发)
|
||||||
|
├── 校验: listing.status=published, account.status=published
|
||||||
|
├── INSERT rental_orders (status=pending_handoff, rent_hours=24 硬编码)
|
||||||
|
├── UPDATE listing.status → rented, account.status → rented
|
||||||
|
├── INSERT account_snapshot (JSON 字段, 记录下单时资产快照)
|
||||||
|
├── INSERT wallet_ledger (order_lock, 模拟冻结 rent_amount + deposit_amount)
|
||||||
|
├── UPDATE wallet_accounts (frozen_balance ↑)
|
||||||
|
├── notification.Append() → 通知号主+租客
|
||||||
|
└── COMMIT
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键发现——无支付环节**:`POST /api/orders` 调用后直接进入 `pending_handoff`,完全没有支付确认步骤。这是因为项目计划明确说"一期先做账务模型,不直接接真实支付和提现"。
|
||||||
|
|
||||||
|
### 2. 当前存在的 9 个关键缺失
|
||||||
|
|
||||||
|
| # | 缺失项 | 影响 | 涉及文件 |
|
||||||
|
|---|--------|------|----------|
|
||||||
|
| 1 | **无支付确认步骤** | 订单创建即生效,无资金冻结验证 | `order/repository.go` Create() |
|
||||||
|
| 2 | **租期硬编码 24h** | `internalOrderHours = 24`,用户无法选择 | `order/repository.go:21` |
|
||||||
|
| 3 | **无余额校验** | 下单时未检查租客 `available_balance >= rent_amount + deposit_amount` | `order/repository.go` Create() |
|
||||||
|
| 4 | **前端余额硬编码** | `MobileProfileView.vue` 写死 `¥0.00`,未调 API | `MobileProfileView.vue` |
|
||||||
|
| 5 | **钱包首次查询才创建** | `GET /api/wallet/balance` 首次访问才初始化账户 | `wallet/repository.go` |
|
||||||
|
| 6 | **无充值入口** | 没有任何充值接口或页面 | 全项目 |
|
||||||
|
| 7 | **站内信前端空壳** | `MobileMessagesView.vue` 只显示 `van-empty` | `MobileMessagesView.vue` |
|
||||||
|
| 8 | **无租期即将结束提醒** | 项目计划中规划了但未实现 | 超时扫描任务 |
|
||||||
|
| 9 | **短信完全未接** | 只有登录验证码的 mock,业务通知短信完全缺失 | `integrations/sms/` |
|
||||||
|
|
||||||
|
### 3. 要完成端到端测试必须补充的最小功能集
|
||||||
|
|
||||||
|
```
|
||||||
|
必须在测试前补充的功能(按依赖顺序):
|
||||||
|
|
||||||
|
第1步:余额充值(测试前置条件)
|
||||||
|
└── POST /api/wallet/recharge {amount} → 给测试用户加余额
|
||||||
|
|
||||||
|
第2步:租期选择(核心流程)
|
||||||
|
└── 前端:下单时增加租期选择组件
|
||||||
|
└── 后端:CreateOrderDTO 增加 rent_hours 字段
|
||||||
|
|
||||||
|
第3步:支付确认步骤(状态机改造)
|
||||||
|
└── 新增订单状态:pending_payment
|
||||||
|
└── POST /api/orders → pending_payment(而非直接 pending_handoff)
|
||||||
|
└── POST /api/orders/{id}/pay → 校验余额 → 扣款 → pending_handoff
|
||||||
|
|
||||||
|
第4步:前端站内信接入
|
||||||
|
└── MobileMessagesView.vue 接入 fetchNotifications API
|
||||||
|
└── 底部导航栏增加未读消息角标
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 支付确认步骤的推荐设计
|
||||||
|
|
||||||
|
```
|
||||||
|
建议的状态机改造(最小侵入):
|
||||||
|
|
||||||
|
POST /api/orders
|
||||||
|
↓
|
||||||
|
[新增] pending_payment(待支付,默认 15 分钟超时自动取消)
|
||||||
|
↓
|
||||||
|
POST /api/orders/{id}/pay
|
||||||
|
├── 校验:订单归属租客、status=pending_payment、未超时
|
||||||
|
├── 校验:wallet_accounts.available_balance >= rent_amount + deposit_amount
|
||||||
|
├── UPDATE wallet_accounts: available_balance -= total, frozen_balance += total
|
||||||
|
├── INSERT wallet_ledger: order_lock (冻结流水)
|
||||||
|
├── UPDATE rental_orders: status → pending_handoff
|
||||||
|
├── UPDATE listing + account: status → rented
|
||||||
|
└── notification.Append() → 通知双方
|
||||||
|
```
|
||||||
|
|
||||||
|
对应的新 API:
|
||||||
|
- `POST /api/orders/{id}/pay` — 确认支付
|
||||||
|
- `GET /api/orders/{id}/payment-status` — 查询支付状态
|
||||||
|
- 超时扫描任务新增:`pending_payment` 超过 15 分钟 → 自动取消
|
||||||
|
|
||||||
|
### 5. 完整测试场景清单
|
||||||
|
|
||||||
|
#### 5.1 正常流程测试
|
||||||
|
|
||||||
|
| 场景 | 步骤 | 验证点 |
|
||||||
|
|------|------|--------|
|
||||||
|
| 完整租赁闭环 | 浏览→下单→支付→号主交接→收号确认→租赁→结账→号主确认→完成 | 所有状态流转正确,资金流水完整 |
|
||||||
|
| 租期选择 | 选择不同租期(1h/3h/12h/24h) | rent_end_at 正确计算,租金按比例 |
|
||||||
|
| 多商品下单 | 不同商品分别下单 | 互不影响,各自锁定 |
|
||||||
|
| 结账修改流程 | 号主修改结账金额→租客同意修正 | 状态流转:pending_checkout_confirm → pending_checkout_accept → completed |
|
||||||
|
|
||||||
|
#### 5.2 边界与异常测试
|
||||||
|
|
||||||
|
| 场景 | 验证点 |
|
||||||
|
|------|--------|
|
||||||
|
| 余额不足下单 | 返回明确错误,不创建订单 |
|
||||||
|
| 已租出商品下单 | `SELECT FOR UPDATE` 后检测到 status≠published,拒绝 |
|
||||||
|
| 审核未通过商品下单 | 拒绝(review_status≠approved) |
|
||||||
|
| 自己租自己 | 拒绝(owner_id = renter_id) |
|
||||||
|
| 未实名租客下单 | 根据 system_config 的 `realname_required_for_order` 配置 |
|
||||||
|
| 非法状态转换 | 已取消订单不能再次取消,已完成订单不能结账等 |
|
||||||
|
| 结账争议流程 | 任一方发起争议→客服仲裁→完成/关闭 |
|
||||||
|
|
||||||
|
#### 5.3 并发测试
|
||||||
|
|
||||||
|
| 场景 | 验证点 |
|
||||||
|
|------|--------|
|
||||||
|
| 2 人同时下单同一商品 | `SELECT FOR UPDATE` 保证只有一人成功 |
|
||||||
|
| 同时支付+超时取消 | 互斥,不会出现既支付又取消 |
|
||||||
|
| 并发结算 | wallet_ledger 不出现负数或重复入账 |
|
||||||
|
| 并发创建订单 | 库存锁定正确 |
|
||||||
|
|
||||||
|
#### 5.4 超时测试(依赖超时扫描任务)
|
||||||
|
|
||||||
|
| 超时类型 | 默认阈值 | 期望行为 |
|
||||||
|
|----------|----------|----------|
|
||||||
|
| 待支付超时 | 15 min | 订单自动取消 |
|
||||||
|
| 号主待交接超时 | 30 min | handoff_status → owner_timeout,通知双方 |
|
||||||
|
| 租客确认收号超时 | 30 min | status → abnormal,客服介入 |
|
||||||
|
| 租客逾期未结账 | 10 min 宽限 | status → overdue |
|
||||||
|
| 号主确认结账超时 | 120 min | status → abnormal |
|
||||||
|
|
||||||
|
#### 5.5 安全测试
|
||||||
|
|
||||||
|
| 场景 | 验证点 |
|
||||||
|
|------|--------|
|
||||||
|
| 未登录访问 | 所有写接口返回 401 |
|
||||||
|
| 跨用户操作 | A 不能取消 B 的订单,不能确认 B 的收号 |
|
||||||
|
| 后台接口保护 | 普通用户 token 不能访问 /api/admin/* |
|
||||||
|
| 重复提交 | 防重机制(前端 loading + 后端幂等) |
|
||||||
|
| SQL 注入 | 所有查询通过 GORM 参数化 |
|
||||||
|
| 越权查看 | 用户不能查看他人订单、钱包、交接记录 |
|
||||||
|
|
||||||
|
### 6. 具体测试实施方案
|
||||||
|
|
||||||
|
#### 6.1 测试环境搭建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 启动基础设施
|
||||||
|
docker compose -f deploy/docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
# 2. 初始化数据库
|
||||||
|
docker exec -i hfb-mysql mysql -uhfb -psecret hfb_sys < backend/migrations/000001_init.sql
|
||||||
|
|
||||||
|
# 3. 启动后端
|
||||||
|
cd backend && cp .env.example .env && go run ./cmd/api
|
||||||
|
|
||||||
|
# 4. 启动前端
|
||||||
|
cd frontend && npm install && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6.2 手动端到端测试步骤(核心路径)
|
||||||
|
|
||||||
|
```
|
||||||
|
前置条件:准备两个测试手机号(号主A + 租客B)
|
||||||
|
|
||||||
|
Step 1: 号主A 注册/登录
|
||||||
|
POST /api/auth/sms/send {phone: "138xxxx0001"}
|
||||||
|
POST /api/auth/sms/login {phone: "138xxxx0001", code: "从日志获取"}
|
||||||
|
|
||||||
|
Step 2: 号主A 实名认证(mock)
|
||||||
|
POST /api/realname/start {name: "张三", id_no: "110101199001011234"}
|
||||||
|
|
||||||
|
Step 3: 号主A 发布商品
|
||||||
|
POST /api/listings {
|
||||||
|
game_name: "三角洲行动",
|
||||||
|
server_region: "微信区",
|
||||||
|
haf_coin_amount: 5000000,
|
||||||
|
price_hourly: 10,
|
||||||
|
deposit_amount: 100,
|
||||||
|
min_rent_hours: 1,
|
||||||
|
max_rent_hours: 24
|
||||||
|
}
|
||||||
|
POST /api/listings/{id}/submit-review
|
||||||
|
|
||||||
|
Step 4: 后台审核通过
|
||||||
|
POST /api/admin/auth/login {username: "admin", password: "admin123456", captcha: "..."}
|
||||||
|
POST /api/admin/listings/{id}/approve
|
||||||
|
|
||||||
|
Step 5: 租客B 注册/登录
|
||||||
|
(同 Step 1,使用 138xxxx0002)
|
||||||
|
|
||||||
|
Step 6: 租客B 充值(当前需手动)
|
||||||
|
直接改数据库或通过调试接口给租客B加余额
|
||||||
|
|
||||||
|
Step 7: 租客B 浏览并下单
|
||||||
|
GET /api/listings → 查看商品
|
||||||
|
POST /api/orders {listing_id, rent_hours: 3}
|
||||||
|
|
||||||
|
Step 8: 租客B 支付(当前直接跳到 pending_handoff,后续需补充)
|
||||||
|
验证订单状态
|
||||||
|
|
||||||
|
Step 9: 号主A 提交交接说明
|
||||||
|
POST /api/orders/{id}/handoff {content: "登录方式:..."}
|
||||||
|
|
||||||
|
Step 10: 租客B 确认收号
|
||||||
|
POST /api/orders/{id}/confirm-receive
|
||||||
|
|
||||||
|
Step 11: 租客B 发起结账
|
||||||
|
POST /api/orders/{id}/checkout {
|
||||||
|
content: "使用结束",
|
||||||
|
consumable_amount: 30,
|
||||||
|
coin_consumed_m: 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
Step 12: 号主A 确认结账
|
||||||
|
POST /api/orders/{id}/checkout/confirm
|
||||||
|
|
||||||
|
Step 13: 验证结果
|
||||||
|
GET /api/orders/{id} → status=completed
|
||||||
|
GET /api/wallet/ledger → 流水完整
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6.3 自动化测试建议
|
||||||
|
|
||||||
|
| 层级 | 工具 | 覆盖内容 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 单元测试 | Go testing + testify | Repository 的事务方法,Service 参数校验 |
|
||||||
|
| 集成测试 | Go testing + testcontainers | 真实 MySQL/Redis 下的完整流程 |
|
||||||
|
| API 测试 | Postman/Newman 或 Go httptest | 所有 API 端点的正常+异常路径 |
|
||||||
|
| E2E 测试 | Playwright 或 Cypress | 前端页面操作完整流程 |
|
||||||
|
| 并发测试 | Go benchmark + goroutines | 并发下单、并发结算 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第二部分:应增加什么消息系统
|
||||||
|
|
||||||
|
### 1. 现有消息系统现状评估
|
||||||
|
|
||||||
|
#### 1.1 站内信体系
|
||||||
|
|
||||||
|
**好消息**:后端站内信的写入非常完整——所有 18 个关键业务节点都在事务内通过 `notification.Append(tx, ...)` 写入,覆盖了订单全生命周期的每个状态变更。
|
||||||
|
|
||||||
|
| 文件 | notification.Append 调用次数 | 场景 |
|
||||||
|
|------|------------------------------|------|
|
||||||
|
| `order/repository.go` | 9 次 | 创建/取消/交接/收号/结账/修改结账/客服关闭/标记异常/完成结算 |
|
||||||
|
| `dispute/repository.go` | 4 次 | 发起申诉(2)/仲裁完成(2) |
|
||||||
|
| `ordertimeout/job.go` | 4 次 | 号主超时/收号超时/逾期/确认结账超时 |
|
||||||
|
|
||||||
|
**坏消息**:前端完全没接——`MobileMessagesView.vue` 只有一个 `van-empty` 组件,`Desktop NotificationsView.vue` 虽然接入了 API 但缺乏未读数角标和分类。
|
||||||
|
|
||||||
|
#### 1.2 表结构分析
|
||||||
|
|
||||||
|
```sql
|
||||||
|
notifications (
|
||||||
|
id, user_id, type, title, content,
|
||||||
|
biz_type, biz_id, read_at, created_at
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**当前优点**:
|
||||||
|
- 简洁高效
|
||||||
|
- `(user_id, read_at, created_at)` 联合索引支持快速分页查询
|
||||||
|
|
||||||
|
**当前不足**:
|
||||||
|
- 缺少 `category` 字段(订单消息/系统通知/公告无法区分)
|
||||||
|
- 缺少软删除标记
|
||||||
|
- 缺少 `extra_data` (JSON) 用于存储跳转链接等扩展信息
|
||||||
|
|
||||||
|
#### 1.3 对照项目计划的缺口
|
||||||
|
|
||||||
|
项目计划第 8 章规划的但**尚未实现**的场景:
|
||||||
|
|
||||||
|
| 规划场景 | 当前状态 | 优先级 |
|
||||||
|
|----------|----------|--------|
|
||||||
|
| 商品审核通过/拒绝通知号主 | ✅ 已实现 | — |
|
||||||
|
| 租期即将结束提醒 | ❌ 未实现 | 🔴 高 |
|
||||||
|
| 账户冻结/解冻通知用户 | ❌ 未实现 | 🟡 中 |
|
||||||
|
| 短信登录验证码 | ✅ mock | — |
|
||||||
|
| 租期即将到期短信 | ❌ 未实现 | 🔴 高 |
|
||||||
|
| 申诉被受理短信 | ❌ 未实现 | 🟡 中 |
|
||||||
|
| 仲裁结果短信 | ❌ 未实现 | 🟡 中 |
|
||||||
|
| WebSocket/SSE 实时推送 | ❌ 未实现 | 🟢 低 |
|
||||||
|
|
||||||
|
### 2. 推荐的消息系统增强架构
|
||||||
|
|
||||||
|
#### 2.1 站内信增强(Phase 1 — 立即做)
|
||||||
|
|
||||||
|
```
|
||||||
|
最小可行方案:
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ notifications 表扩展 │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ + category VARCHAR(32) -- order|system|announce │
|
||||||
|
│ + link_type VARCHAR(32) -- order_detail|... │
|
||||||
|
│ + link_id BIGINT │
|
||||||
|
│ + extra_data JSON -- 扩展数据 │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ 新增 API: │
|
||||||
|
│ GET /api/notifications/unread-count │
|
||||||
|
│ POST /api/notifications/batch-read │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
前端改动:
|
||||||
|
├── MobileMessagesView.vue:接入 fetchNotifications,消息列表
|
||||||
|
├── 底部导航栏:未读消息红点角标
|
||||||
|
├── Desktop NotificationsView.vue:增强消息分类和已读标记
|
||||||
|
└── Router:增加消息未读数 store
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2 短信系统增强(Phase 2 — 短期)
|
||||||
|
|
||||||
|
```
|
||||||
|
设计短信 Provider 抽象层:
|
||||||
|
|
||||||
|
internal/integrations/sms/
|
||||||
|
├── provider.go ← Provider 接口
|
||||||
|
│ type Provider interface {
|
||||||
|
│ Send(ctx, phone, templateCode string, params map[string]string) error
|
||||||
|
│ SendBatch(ctx, phones []string, ...) error
|
||||||
|
│ }
|
||||||
|
├── mock.go ← MockProvider(开发环境)
|
||||||
|
├── aliyun.go ← AliyunProvider(生产环境)
|
||||||
|
├── limiter.go ← 限流器(基于 Redis)
|
||||||
|
│ - 手机号维度:60s/次,10次/天
|
||||||
|
│ - IP 维度:100次/小时
|
||||||
|
│ - 场景维度:可配置
|
||||||
|
└── logger.go ← 发送日志(sms_logs 表)
|
||||||
|
|
||||||
|
短信触发场景:
|
||||||
|
├── 登录验证码(已有 mock)
|
||||||
|
├── 租期即将到期提醒(新增 🔴)
|
||||||
|
├── 订单申诉被受理(新增 🟡)
|
||||||
|
├── 仲裁结果通知(新增 🟡)
|
||||||
|
└── 高风险安全提醒(预留)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.3 SSE 实时推送架构(Phase 3 — 中期)
|
||||||
|
|
||||||
|
```
|
||||||
|
推荐 SSE(Server-Sent Events)而非 WebSocket:
|
||||||
|
理由:本场景是单向推送(服务端→客户端),SSE 更轻量,
|
||||||
|
基于 HTTP,不需要额外协议升级,断线自动重连。
|
||||||
|
|
||||||
|
Go 侧架构:
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ SSEBroker (单例) │
|
||||||
|
├──────────────────────────────────────┤
|
||||||
|
│ clients map[userID]*SSEClient │
|
||||||
|
│ Notify(userID, event) │
|
||||||
|
│ Broadcast(event) │
|
||||||
|
└──────────────────────────────────────┘
|
||||||
|
↓ 事件分发
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ SSE Events: │
|
||||||
|
│ - order.status_changed │
|
||||||
|
│ - order.handoff_updated │
|
||||||
|
│ - order.checkout_updated │
|
||||||
|
│ - notification.new │
|
||||||
|
│ - dispute.updated │
|
||||||
|
└──────────────────────────────────────┘
|
||||||
|
|
||||||
|
Vue 侧:
|
||||||
|
// composables/useSSE.ts
|
||||||
|
const eventSource = new EventSource('/api/sse/stream?token=xxx')
|
||||||
|
eventSource.addEventListener('notification.new', (e) => {
|
||||||
|
const data = JSON.parse(e.data)
|
||||||
|
notificationStore.incrementUnread()
|
||||||
|
})
|
||||||
|
|
||||||
|
重连策略:
|
||||||
|
- EventSource 原生支持自动重连
|
||||||
|
- 可设置 retry interval: 3000ms
|
||||||
|
- 重连时重新验证 token
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.4 订单全链路状态变更推送矩阵
|
||||||
|
|
||||||
|
```
|
||||||
|
订单状态变更 → 推送内容设计:
|
||||||
|
|
||||||
|
pending_payment → 租客: {type:"order.created", title:"订单已创建", orderId, amount, expireAt}
|
||||||
|
号主: {type:"order.new_rental", title:"有新的租用请求"}
|
||||||
|
|
||||||
|
pending_handoff → 租客: {type:"order.paid", title:"支付成功,等待号主交接"}
|
||||||
|
号主: {type:"order.pending_handoff", title:"请尽快完成账号交接"}
|
||||||
|
|
||||||
|
pending_renter_confirm → 租客: {type:"handoff.submitted", title:"号主已完成交接,请确认收号"}
|
||||||
|
|
||||||
|
renting → 号主: {type:"handoff.confirmed", title:"租客已确认收号,租赁开始"}
|
||||||
|
|
||||||
|
[租期结束前5分钟] → 租客: {type:"rental.expiring", title:"租期即将结束"}
|
||||||
|
|
||||||
|
pending_checkout_confirm → 号主: {type:"checkout.submitted", title:"租客已发起结账"}
|
||||||
|
|
||||||
|
completed → 双方: {type:"order.completed", title:"订单已完成"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 数据库设计建议
|
||||||
|
|
||||||
|
#### 3.1 新增表
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 短信发送日志
|
||||||
|
CREATE TABLE sms_logs (
|
||||||
|
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
phone VARCHAR(20) NOT NULL,
|
||||||
|
template_code VARCHAR(64) NOT NULL,
|
||||||
|
template_params JSON,
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'pending', -- pending|sent|failed
|
||||||
|
provider VARCHAR(32) NOT NULL,
|
||||||
|
provider_msg_id VARCHAR(128),
|
||||||
|
error_msg VARCHAR(512),
|
||||||
|
biz_type VARCHAR(32),
|
||||||
|
biz_id BIGINT UNSIGNED,
|
||||||
|
user_id BIGINT UNSIGNED,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_sms_logs_phone_created (phone, created_at),
|
||||||
|
INDEX idx_sms_logs_status (status)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- SSE 推送记录(可选,用于对账)
|
||||||
|
CREATE TABLE push_records (
|
||||||
|
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
event_type VARCHAR(64) NOT NULL,
|
||||||
|
payload JSON,
|
||||||
|
delivered TINYINT DEFAULT 0,
|
||||||
|
delivered_at DATETIME,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_push_records_user (user_id, created_at)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2 notifications 表扩展
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE notifications
|
||||||
|
ADD COLUMN category VARCHAR(32) NOT NULL DEFAULT 'order'
|
||||||
|
COMMENT 'order|system|announce|security',
|
||||||
|
ADD COLUMN link_type VARCHAR(32) DEFAULT NULL
|
||||||
|
COMMENT 'order_detail|listing_detail|dispute_detail|wallet|url',
|
||||||
|
ADD COLUMN link_id BIGINT UNSIGNED DEFAULT NULL,
|
||||||
|
ADD COLUMN extra_data JSON DEFAULT NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 实施优先级总览
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1(立即 — 让消息系统可用)
|
||||||
|
├── ✅ 让 MobileMessagesView.vue 接入 fetchNotifications
|
||||||
|
├── ✅ 底部导航栏增加未读消息角标
|
||||||
|
├── ✅ 增加 GET /api/notifications/unread-count API
|
||||||
|
├── ✅ 增加 POST /api/notifications/batch-read API
|
||||||
|
└── ✅ 消息列表支持点击跳转到对应订单详情
|
||||||
|
|
||||||
|
Phase 2(短期 — 完善通知体系)
|
||||||
|
├── notifications 表扩展 category/link_type/extra_data
|
||||||
|
├── 实现租期即将结束通知(站内信 + 前端定时检查)
|
||||||
|
├── 实现用户冻结/解冻通知
|
||||||
|
├── 短信 Provider 抽象层 + 限流器
|
||||||
|
├── sms_logs 表 + 短信日志后台查看
|
||||||
|
└── 接入一个真实短信服务商
|
||||||
|
|
||||||
|
Phase 3(中期 — 实时体验)
|
||||||
|
├── SSE Broker 实现
|
||||||
|
├── 订单状态实时推送接入前端
|
||||||
|
├── 租期倒计时实时更新
|
||||||
|
├── 客服消息实时通知
|
||||||
|
└── push_records 表(可选)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录:关键文件索引
|
||||||
|
|
||||||
|
### 后端核心文件
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `backend/internal/modules/order/repository.go` | 订单 Repository,核心业务逻辑(1089行) |
|
||||||
|
| `backend/internal/modules/order/handler.go` | 订单 HTTP Handler(376行) |
|
||||||
|
| `backend/internal/modules/order/service.go` | 订单 Service 层(160行) |
|
||||||
|
| `backend/internal/modules/order/dto.go` | 订单 DTO 定义(126行) |
|
||||||
|
| `backend/internal/model/order.go` | RentalOrder 模型定义 |
|
||||||
|
| `backend/internal/modules/wallet/repository.go` | 钱包 Repository + AppendEntries |
|
||||||
|
| `backend/internal/modules/notification/repository.go` | 通知 Repository + Append |
|
||||||
|
| `backend/internal/modules/dispute/repository.go` | 争议仲裁 Repository |
|
||||||
|
| `backend/internal/jobs/ordertimeout/job.go` | 超时扫描定时任务 |
|
||||||
|
|
||||||
|
### 前端核心文件
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `frontend/src/views/mobile/MobileListingDetailView.vue` | 商品详情/下单页 |
|
||||||
|
| `frontend/src/views/mobile/MobileOrdersView.vue` | 订单列表页 |
|
||||||
|
| `frontend/src/views/mobile/MobileMessagesView.vue` | 消息页(空壳) |
|
||||||
|
| `frontend/src/views/mobile/MobileProfileView.vue` | 个人中心(余额硬编码) |
|
||||||
|
| `frontend/src/api/orders.ts` | 订单 API 定义 |
|
||||||
|
| `frontend/src/api/notifications.ts` | 通知 API 定义 |
|
||||||
|
| `frontend/src/api/wallet.ts` | 钱包 API 定义 |
|
||||||
+28
-21
@@ -113,7 +113,7 @@
|
|||||||
|
|
||||||
- 租客选择商品和租期后创建订单。
|
- 租客选择商品和租期后创建订单。
|
||||||
- 系统锁定商品库存,避免同一账号被重复出租。
|
- 系统锁定商品库存,避免同一账号被重复出租。
|
||||||
- 订单流转覆盖待确认、待交接、租赁中、待归还确认、已完成、已取消、申诉中。
|
- 订单流转覆盖待确认、待交接、租赁中、待号主确认结账、待租客确认修正、结账争议中、已完成、已取消、申诉中。
|
||||||
|
|
||||||
钱包账务:
|
钱包账务:
|
||||||
|
|
||||||
@@ -142,10 +142,10 @@
|
|||||||
2. 订单确认后,商品进入锁定状态,订单进入待交接。
|
2. 订单确认后,商品进入锁定状态,订单进入待交接。
|
||||||
3. 号主在订单内提交交接说明,例如登录方式、注意事项、联系说明或外部交接备注。
|
3. 号主在订单内提交交接说明,例如登录方式、注意事项、联系说明或外部交接备注。
|
||||||
4. 租客确认收到账号后,订单进入租赁中,并记录租赁开始时间和预计结束时间。
|
4. 租客确认收到账号后,订单进入租赁中,并记录租赁开始时间和预计结束时间。
|
||||||
5. 租期即将结束时,系统通过站内信和短信提醒租客归还。
|
5. 租期即将结束时,系统通过站内信和短信提醒租客完成使用并发起结账。
|
||||||
6. 租客提交归还申请,上传必要的租后截图或说明。
|
6. 租客发起结账,填写使用结束说明、消耗金额、哈夫币消耗量和必要证据。
|
||||||
7. 号主确认账号状态无误后,订单进入结算。
|
7. 号主确认账号状态无误后直接完成结算,或修改结账金额后交由租客确认。
|
||||||
8. 如任一方对交接、使用、归还或资产变化有异议,订单进入申诉仲裁。
|
8. 租客同意修正后订单完成;拒绝修正或任一方对交接、使用、结账、资产变化有异议时,订单进入申诉仲裁。
|
||||||
|
|
||||||
回收边界:
|
回收边界:
|
||||||
|
|
||||||
@@ -158,8 +158,8 @@
|
|||||||
|
|
||||||
- 号主待交接超时:订单进入待交接后,默认 30 分钟未提交交接说明,租客可取消订单或发起申诉。
|
- 号主待交接超时:订单进入待交接后,默认 30 分钟未提交交接说明,租客可取消订单或发起申诉。
|
||||||
- 租客确认收号超时:号主提交交接说明后,默认 30 分钟未确认收号,订单进入客服介入,不自动开始租期。
|
- 租客确认收号超时:号主提交交接说明后,默认 30 分钟未确认收号,订单进入客服介入,不自动开始租期。
|
||||||
- 租客归还超时:超过租期结束时间仍未提交归还,系统提醒后进入逾期中,号主可发起申诉。
|
- 租客结账超时:超过租期结束时间仍未发起结账,系统提醒后进入逾期中,号主可发起申诉。
|
||||||
- 号主确认归还超时:租客提交归还后,默认 2 小时未确认,订单进入自动确认候选或客服复核。
|
- 号主确认结账超时:租客发起结账后,默认 2 小时未确认,订单进入客服复核。
|
||||||
- 所有超时阈值必须进入 `system_configs` 管理,后台可调整。
|
- 所有超时阈值必须进入 `system_configs` 管理,后台可调整。
|
||||||
|
|
||||||
订单状态机:
|
订单状态机:
|
||||||
@@ -173,12 +173,16 @@
|
|||||||
| 待交接 | 申诉中 | 租客/号主 | 交接争议或安全验证无法完成 |
|
| 待交接 | 申诉中 | 租客/号主 | 交接争议或安全验证无法完成 |
|
||||||
| 待收号确认 | 租赁中 | 租客 | 租客确认收到账号并可正常登录 |
|
| 待收号确认 | 租赁中 | 租客 | 租客确认收到账号并可正常登录 |
|
||||||
| 待收号确认 | 申诉中 | 租客/系统 | 租客确认超时、无法登录或描述不符 |
|
| 待收号确认 | 申诉中 | 租客/系统 | 租客确认超时、无法登录或描述不符 |
|
||||||
| 租赁中 | 逾期中 | 系统 | 超过租期结束时间仍未提交归还 |
|
| 租赁中 | 逾期中 | 系统 | 超过租期结束时间仍未发起结账 |
|
||||||
| 租赁中 | 待归还确认 | 租客 | 租客提交归还和租后凭证 |
|
| 租赁中 | 待号主确认结账 | 租客 | 租客发起结账和租后凭证 |
|
||||||
| 逾期中 | 待归还确认 | 租客 | 租客补交归还申请 |
|
| 逾期中 | 待号主确认结账 | 租客 | 租客补交结账申请 |
|
||||||
| 逾期中 | 申诉中 | 号主/系统 | 逾期未归还或疑似资产损失 |
|
| 逾期中 | 申诉中 | 号主/系统 | 逾期未归还或疑似资产损失 |
|
||||||
| 待归还确认 | 已完成 | 号主/系统 | 号主确认归还,或超时后客服/系统确认 |
|
| 待号主确认结账 | 已完成 | 号主 | 号主确认结账 |
|
||||||
| 待归还确认 | 申诉中 | 号主/租客 | 归还争议、资产损失或封号异常 |
|
| 待号主确认结账 | 待租客确认修正 | 号主 | 号主修改结账金额 |
|
||||||
|
| 待租客确认修正 | 已完成 | 租客 | 租客同意修正结账 |
|
||||||
|
| 待号主确认结账 | 结账争议中 | 任一方 | 发起结账争议 |
|
||||||
|
| 待租客确认修正 | 结账争议中 | 任一方 | 发起结账争议 |
|
||||||
|
| 结账争议中 | 已完成/已关闭/异常 | 客服 | 后台仲裁 |
|
||||||
| 申诉中 | 已完成 | 客服 | 仲裁为正常完成或部分扣款后完成 |
|
| 申诉中 | 已完成 | 客服 | 仲裁为正常完成或部分扣款后完成 |
|
||||||
| 申诉中 | 已取消 | 客服 | 仲裁为订单取消和退款 |
|
| 申诉中 | 已取消 | 客服 | 仲裁为订单取消和退款 |
|
||||||
| 申诉中 | 已关闭 | 客服 | 仲裁为异常关闭、冻结用户或冻结商品 |
|
| 申诉中 | 已关闭 | 客服 | 仲裁为异常关闭、冻结用户或冻结商品 |
|
||||||
@@ -216,7 +220,7 @@
|
|||||||
结算规则:
|
结算规则:
|
||||||
|
|
||||||
- 正常完成订单后,租金按平台抽成规则分账给号主。
|
- 正常完成订单后,租金按平台抽成规则分账给号主。
|
||||||
- 押金在号主确认归还后释放给租客。
|
- 押金在双方结账确认或客服仲裁后释放给租客。
|
||||||
- 如发生资产损失、无法登录、封号或超时归还,可由客服仲裁后扣除部分或全部押金。
|
- 如发生资产损失、无法登录、封号或超时归还,可由客服仲裁后扣除部分或全部押金。
|
||||||
- 如号主虚假描述、超时未交接或账号无法使用,可裁定全额或部分退款。
|
- 如号主虚假描述、超时未交接或账号无法使用,可裁定全额或部分退款。
|
||||||
- 仲裁导致的扣款、退款、赔付必须生成资金流水和审计日志。
|
- 仲裁导致的扣款、退款、赔付必须生成资金流水和审计日志。
|
||||||
@@ -281,8 +285,9 @@
|
|||||||
- 号主提交交接说明。
|
- 号主提交交接说明。
|
||||||
- 租客确认收号。
|
- 租客确认收号。
|
||||||
- 租期即将结束。
|
- 租期即将结束。
|
||||||
- 租客提交归还。
|
- 租客发起结账。
|
||||||
- 号主确认归还。
|
- 号主确认或修改结账。
|
||||||
|
- 租客确认或拒绝修正结账。
|
||||||
- 订单完成结算。
|
- 订单完成结算。
|
||||||
- 发起申诉。
|
- 发起申诉。
|
||||||
- 仲裁结果。
|
- 仲裁结果。
|
||||||
@@ -592,8 +597,10 @@
|
|||||||
- `GET /api/orders/{id}`:订单详情。
|
- `GET /api/orders/{id}`:订单详情。
|
||||||
- `POST /api/orders/{id}/cancel`:取消订单。
|
- `POST /api/orders/{id}/cancel`:取消订单。
|
||||||
- `POST /api/orders/{id}/confirm-receive`:租客确认收号。
|
- `POST /api/orders/{id}/confirm-receive`:租客确认收号。
|
||||||
- `POST /api/orders/{id}/return`:租客提交归还。
|
- `POST /api/orders/{id}/checkout`:租客发起结账。
|
||||||
- `POST /api/orders/{id}/confirm-return`:号主确认归还。
|
- `POST /api/orders/{id}/checkout/confirm`:号主确认结账。
|
||||||
|
- `POST /api/orders/{id}/checkout/counter`:号主修改结账。
|
||||||
|
- `POST /api/orders/{id}/checkout/accept`:租客同意修正结账。
|
||||||
- `POST /api/orders/{id}/dispute`:发起申诉。
|
- `POST /api/orders/{id}/dispute`:发起申诉。
|
||||||
|
|
||||||
交接:
|
交接:
|
||||||
@@ -835,8 +842,8 @@ hfb_sys/
|
|||||||
- 实现订单账号资产快照。
|
- 实现订单账号资产快照。
|
||||||
- 实现号主提交交接说明。
|
- 实现号主提交交接说明。
|
||||||
- 实现租客确认收号。
|
- 实现租客确认收号。
|
||||||
- 实现租客提交归还、号主确认归还。
|
- 实现租客发起结账、号主确认或修改结账、租客确认或拒绝修正结账。
|
||||||
- 实现交接、确认收号、归还、确认归还的超时处理。
|
- 实现交接、确认收号、结账逾期、确认结账超时处理。
|
||||||
- 实现钱包账户、押金冻结、租金流水、结算流水。
|
- 实现钱包账户、押金冻结、租金流水、结算流水。
|
||||||
- 预留支付和提现接口。
|
- 预留支付和提现接口。
|
||||||
|
|
||||||
@@ -887,9 +894,9 @@ hfb_sys/
|
|||||||
- 商品草稿、待审核、已上架、已下架、审核拒绝状态流转正确。
|
- 商品草稿、待审核、已上架、已下架、审核拒绝状态流转正确。
|
||||||
- 审核未通过商品不能下单。
|
- 审核未通过商品不能下单。
|
||||||
- 同一账号并发下单只能成功一单。
|
- 同一账号并发下单只能成功一单。
|
||||||
- 订单待交接、租赁中、待归还确认、已完成、申诉中状态流转正确。
|
- 订单待交接、租赁中、待号主确认结账、待租客确认修正、结账争议中、已完成、申诉中状态流转正确。
|
||||||
- 非法订单状态转换会被拒绝。
|
- 非法订单状态转换会被拒绝。
|
||||||
- 交接、收号、归还、确认归还超时会按配置触发取消、申诉、逾期或客服复核。
|
- 交接、收号、结账逾期、确认结账超时会按配置触发取消、申诉、逾期或客服复核。
|
||||||
- 押金冻结、释放、扣款、退款流水一致。
|
- 押金冻结、释放、扣款、退款流水一致。
|
||||||
- 每笔 `wallet_ledger` 正确记录 `balance_after`。
|
- 每笔 `wallet_ledger` 正确记录 `balance_after`。
|
||||||
- 租金、平台抽成、号主结算流水一致。
|
- 租金、平台抽成、号主结算流水一致。
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { apiClient } from './client'
|
import { apiClient } from './client'
|
||||||
|
|
||||||
|
import { type PaginatedResult } from './orders'
|
||||||
|
|
||||||
export interface AdminAuditLog {
|
export interface AdminAuditLog {
|
||||||
id: number
|
id: number
|
||||||
actor_type: string
|
actor_type: string
|
||||||
@@ -19,7 +21,8 @@ export interface AdminAuditQuery {
|
|||||||
actor_id?: string
|
actor_id?: string
|
||||||
action?: string
|
action?: string
|
||||||
biz_type?: string
|
biz_type?: string
|
||||||
limit?: number
|
page?: number
|
||||||
|
page_size?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
@@ -30,6 +33,6 @@ interface ApiResponse<T> {
|
|||||||
|
|
||||||
export async function fetchAdminAuditLogs(query: AdminAuditQuery = {}) {
|
export async function fetchAdminAuditLogs(query: AdminAuditQuery = {}) {
|
||||||
const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined))
|
const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined))
|
||||||
const { data } = await apiClient.get<ApiResponse<{ items: AdminAuditLog[] }>>('/admin/audit-logs', { params })
|
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminAuditLog>>>('/admin/audit-logs', { params })
|
||||||
return data.data.items
|
return data.data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,3 +56,12 @@ export async function logoutAdmin() {
|
|||||||
const { data } = await apiClient.post<ApiResponse<{ logged_out: boolean }>>('/admin/auth/logout')
|
const { data } = await apiClient.post<ApiResponse<{ logged_out: boolean }>>('/admin/auth/logout')
|
||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Manually refresh admin token (uses raw axios to avoid interceptor recursion) */
|
||||||
|
export async function refreshAdminSession() {
|
||||||
|
const refreshToken = localStorage.getItem('admin_refresh_token')
|
||||||
|
if (!refreshToken) throw new Error('no refresh token')
|
||||||
|
const axios = (await import('axios')).default
|
||||||
|
const { data } = await axios.post('/api/admin/auth/refresh', { refresh_token: refreshToken })
|
||||||
|
return data.data as AdminTokenPair
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { apiClient } from './client'
|
import { apiClient } from './client'
|
||||||
|
|
||||||
|
import { type PaginatedResult } from './orders'
|
||||||
|
|
||||||
export interface AdminUserItem {
|
export interface AdminUserItem {
|
||||||
id: number
|
id: number
|
||||||
phone: string
|
phone: string
|
||||||
@@ -22,9 +24,11 @@ interface ApiResponse<T> {
|
|||||||
data: T
|
data: T
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAdminUsers() {
|
export async function fetchAdminUsers(page = 1, pageSize = 20) {
|
||||||
const { data } = await apiClient.get<ApiResponse<{ items: AdminUserItem[] }>>('/admin/users')
|
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminUserItem>>>('/admin/users', {
|
||||||
return data.data.items
|
params: { page, page_size: pageSize },
|
||||||
|
})
|
||||||
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function freezeAdminUser(id: number, reason: string) {
|
export async function freezeAdminUser(id: number, reason: string) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { apiClient } from './client'
|
import { apiClient } from './client'
|
||||||
|
|
||||||
|
import { type PaginatedResult } from './orders'
|
||||||
|
|
||||||
export interface AdminWalletLedger {
|
export interface AdminWalletLedger {
|
||||||
id: number
|
id: number
|
||||||
ledger_no: string
|
ledger_no: string
|
||||||
@@ -22,7 +24,8 @@ export interface AdminWalletLedgerQuery {
|
|||||||
user_id?: string
|
user_id?: string
|
||||||
order_id?: string
|
order_id?: string
|
||||||
biz_type?: string
|
biz_type?: string
|
||||||
limit?: number
|
page?: number
|
||||||
|
page_size?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
@@ -33,6 +36,6 @@ interface ApiResponse<T> {
|
|||||||
|
|
||||||
export async function fetchAdminWalletLedger(query: AdminWalletLedgerQuery = {}) {
|
export async function fetchAdminWalletLedger(query: AdminWalletLedgerQuery = {}) {
|
||||||
const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined))
|
const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined))
|
||||||
const { data } = await apiClient.get<ApiResponse<{ items: AdminWalletLedger[] }>>('/admin/wallet/ledger', { params })
|
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminWalletLedger>>>('/admin/wallet/ledger', { params })
|
||||||
return data.data.items
|
return data.data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,3 +50,11 @@ export async function updateMe(payload: Pick<AuthUser, 'nickname' | 'avatar_url'
|
|||||||
const { data } = await apiClient.put<ApiResponse<AuthUser>>('/me', payload)
|
const { data } = await apiClient.put<ApiResponse<AuthUser>>('/me', payload)
|
||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Manually refresh user token (for store to call on app init) */
|
||||||
|
export async function refreshUserToken(refreshToken: string) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<TokenPair>>('/auth/refresh', {
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
})
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
+100
-23
@@ -1,10 +1,53 @@
|
|||||||
import axios from 'axios'
|
import axios, { type InternalAxiosRequestConfig } from 'axios'
|
||||||
|
|
||||||
export const apiClient = axios.create({
|
export const apiClient = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---- refresh retry lock ----
|
||||||
|
let isRefreshing = false
|
||||||
|
let pendingRequests: Array<(token: string) => void> = []
|
||||||
|
|
||||||
|
function subscribePendingRequests(token: string) {
|
||||||
|
pendingRequests.forEach((cb) => cb(token))
|
||||||
|
pendingRequests.length = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPendingRequest(callback: (token: string) => void) {
|
||||||
|
pendingRequests.push(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshTokenAndRetry(isAdmin = false): Promise<string> {
|
||||||
|
const refreshTokenKey = isAdmin ? 'admin_refresh_token' : 'refresh_token'
|
||||||
|
const refreshToken = localStorage.getItem(refreshTokenKey)
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new Error('no refresh token')
|
||||||
|
}
|
||||||
|
const endpoint = isAdmin ? '/admin/auth/refresh' : '/auth/refresh'
|
||||||
|
// use raw axios (not apiClient) to avoid interceptor recursion
|
||||||
|
const { data } = await axios.post(endpoint, { refresh_token: refreshToken })
|
||||||
|
const newAccessToken = data.data.access_token
|
||||||
|
const newRefreshToken = data.data.refresh_token
|
||||||
|
const accessKey = isAdmin ? 'admin_access_token' : 'access_token'
|
||||||
|
localStorage.setItem(accessKey, newAccessToken)
|
||||||
|
localStorage.setItem(refreshTokenKey, newRefreshToken)
|
||||||
|
return newAccessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// clear user tokens
|
||||||
|
function clearUserTokens() {
|
||||||
|
;['access_token', 'refresh_token', 'user_id', 'phone', 'nickname', 'avatar_url', 'realname_status']
|
||||||
|
.forEach((k) => localStorage.removeItem(k))
|
||||||
|
}
|
||||||
|
|
||||||
|
// clear admin tokens
|
||||||
|
function clearAdminTokens() {
|
||||||
|
;['admin_access_token', 'admin_refresh_token', 'admin_id', 'admin_username']
|
||||||
|
.forEach((k) => localStorage.removeItem(k))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Request interceptor ----
|
||||||
apiClient.interceptors.request.use((config) => {
|
apiClient.interceptors.request.use((config) => {
|
||||||
const url = config.url || ''
|
const url = config.url || ''
|
||||||
const tokenKey = url.startsWith('/admin') ? 'admin_access_token' : 'access_token'
|
const tokenKey = url.startsWith('/admin') ? 'admin_access_token' : 'access_token'
|
||||||
@@ -15,40 +58,74 @@ apiClient.interceptors.request.use((config) => {
|
|||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---- Response interceptor ----
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
async (error) => {
|
||||||
if (error?.response?.status !== 401) {
|
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||||||
|
|
||||||
|
if (error?.response?.status !== 401 || originalRequest._retry) {
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestUrl = error.config?.url || ''
|
const requestUrl = originalRequest.url || ''
|
||||||
const isAdminRequest = requestUrl.startsWith('/admin')
|
const isAdminRequest = requestUrl.startsWith('/admin')
|
||||||
const currentPath = window.location.pathname + window.location.search
|
|
||||||
|
|
||||||
if (isAdminRequest) {
|
// exclude refresh endpoints themselves to avoid dead loop
|
||||||
localStorage.removeItem('admin_access_token')
|
if (requestUrl.endsWith('/auth/refresh') || requestUrl.endsWith('/admin/auth/refresh')) {
|
||||||
localStorage.removeItem('admin_refresh_token')
|
if (isAdminRequest) clearAdminTokens()
|
||||||
if (!window.location.pathname.startsWith('/admin/login')) {
|
else clearUserTokens()
|
||||||
|
if (isAdminRequest && !window.location.pathname.startsWith('/admin/login')) {
|
||||||
window.location.assign('/admin/login')
|
window.location.assign('/admin/login')
|
||||||
|
} else if (!isAdminRequest) {
|
||||||
|
const currentPath = window.location.pathname + window.location.search
|
||||||
|
if (!currentPath.startsWith('/m/login') && !currentPath.startsWith('/login')) {
|
||||||
|
const redirect = encodeURIComponent(currentPath)
|
||||||
|
const loginPath = currentPath.startsWith('/m') ? '/m/login' : '/login'
|
||||||
|
window.location.assign(`${loginPath}?redirect=${redirect}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.removeItem('access_token')
|
// if already refreshing, queue up
|
||||||
localStorage.removeItem('refresh_token')
|
if (isRefreshing) {
|
||||||
localStorage.removeItem('user_id')
|
return new Promise((resolve) => {
|
||||||
localStorage.removeItem('phone')
|
addPendingRequest((newToken: string) => {
|
||||||
localStorage.removeItem('nickname')
|
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||||
localStorage.removeItem('avatar_url')
|
resolve(apiClient(originalRequest))
|
||||||
localStorage.removeItem('realname_status')
|
})
|
||||||
|
})
|
||||||
if (!window.location.pathname.startsWith('/m/login') && !window.location.pathname.startsWith('/m/register')) {
|
|
||||||
const redirect = encodeURIComponent(currentPath)
|
|
||||||
const loginPath = window.location.pathname.startsWith('/m') ? '/m/login' : '/login'
|
|
||||||
window.location.assign(`${loginPath}?redirect=${redirect}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.reject(error)
|
// attempt refresh
|
||||||
},
|
isRefreshing = true
|
||||||
|
try {
|
||||||
|
const newToken = await refreshTokenAndRetry(isAdminRequest)
|
||||||
|
subscribePendingRequests(newToken)
|
||||||
|
originalRequest._retry = true
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||||
|
return apiClient(originalRequest)
|
||||||
|
} catch {
|
||||||
|
// refresh failed, clear tokens and redirect to login
|
||||||
|
subscribePendingRequests('') // let queued requests fail
|
||||||
|
if (isAdminRequest) {
|
||||||
|
clearAdminTokens()
|
||||||
|
if (!window.location.pathname.startsWith('/admin/login')) {
|
||||||
|
window.location.assign('/admin/login')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
clearUserTokens()
|
||||||
|
const currentPath = window.location.pathname + window.location.search
|
||||||
|
if (!currentPath.startsWith('/m/login') && !currentPath.startsWith('/login')) {
|
||||||
|
const redirect = encodeURIComponent(currentPath)
|
||||||
|
const loginPath = currentPath.startsWith('/m') ? '/m/login' : '/login'
|
||||||
|
window.location.assign(`${loginPath}?redirect=${redirect}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { apiClient } from './client'
|
import { apiClient } from './client'
|
||||||
|
|
||||||
|
import { type PaginatedResult } from './orders'
|
||||||
|
|
||||||
export interface Dispute {
|
export interface Dispute {
|
||||||
id: number
|
id: number
|
||||||
order_id: number
|
order_id: number
|
||||||
@@ -30,14 +32,18 @@ export async function createDispute(orderId: number, payload: { type: string; de
|
|||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchDisputes() {
|
export async function fetchDisputes(page = 1, pageSize = 20) {
|
||||||
const { data } = await apiClient.get<ApiResponse<{ items: Dispute[] }>>('/disputes')
|
const { data } = await apiClient.get<ApiResponse<PaginatedResult<Dispute>>>('/disputes', {
|
||||||
return data.data.items
|
params: { page, page_size: pageSize },
|
||||||
|
})
|
||||||
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAdminDisputes() {
|
export async function fetchAdminDisputes(page = 1, pageSize = 20) {
|
||||||
const { data } = await apiClient.get<ApiResponse<{ items: Dispute[] }>>('/admin/disputes')
|
const { data } = await apiClient.get<ApiResponse<PaginatedResult<Dispute>>>('/admin/disputes', {
|
||||||
return data.data.items
|
params: { page, page_size: pageSize },
|
||||||
|
})
|
||||||
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function arbitrateDispute(id: number, payload: { result: string; remark: string; amount?: number }) {
|
export async function arbitrateDispute(id: number, payload: { result: string; remark: string; amount?: number }) {
|
||||||
|
|||||||
+121
-76
@@ -1,124 +1,169 @@
|
|||||||
import { apiClient } from './client'
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
import { type PaginatedResult } from "./orders";
|
||||||
|
|
||||||
export interface Listing {
|
export interface Listing {
|
||||||
id: number
|
id: number;
|
||||||
account_id: number
|
account_id: number;
|
||||||
owner_id: number
|
owner_id: number;
|
||||||
owner_phone?: string
|
owner_phone?: string;
|
||||||
owner_nickname?: string
|
owner_nickname?: string;
|
||||||
title: string
|
title: string;
|
||||||
description: string
|
description: string;
|
||||||
game_name: string
|
game_name: string;
|
||||||
server_region: string
|
server_region: string;
|
||||||
login_platform: string
|
login_platform: string;
|
||||||
rank_level: string
|
rank_level: string;
|
||||||
haf_coin_amount: number
|
haf_coin_amount: number;
|
||||||
asset_summary?: Record<string, unknown>
|
asset_summary?: Record<string, unknown>;
|
||||||
screenshot_urls: string[]
|
screenshot_urls: string[];
|
||||||
cover_url: string
|
cover_url: string;
|
||||||
price_hourly: number
|
price_hourly: number;
|
||||||
price_daily: number
|
price_daily: number;
|
||||||
price_weekly: number
|
price_weekly: number;
|
||||||
deposit_amount: number
|
deposit_amount: number;
|
||||||
is_accelerated_sale?: boolean
|
is_accelerated_sale?: boolean;
|
||||||
status: string
|
status: string;
|
||||||
review_status: string
|
review_status: string;
|
||||||
review_reason: string
|
review_reason: string;
|
||||||
published_at?: string
|
published_at?: string;
|
||||||
created_at: string
|
created_at: string;
|
||||||
updated_at: string
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListingPayload {
|
export interface ListingPayload {
|
||||||
title: string
|
title: string;
|
||||||
description: string
|
description: string;
|
||||||
server_region: string
|
server_region: string;
|
||||||
login_platform: string
|
login_platform: string;
|
||||||
rank_level: string
|
rank_level: string;
|
||||||
haf_coin_amount: number
|
haf_coin_amount: number;
|
||||||
asset_summary?: Record<string, unknown>
|
asset_summary?: Record<string, unknown>;
|
||||||
screenshot_urls: string[]
|
screenshot_urls: string[];
|
||||||
price_hourly: number
|
price_hourly: number;
|
||||||
price_daily: number
|
price_daily: number;
|
||||||
price_weekly: number
|
price_weekly: number;
|
||||||
deposit_amount: number
|
deposit_amount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
code: string
|
code: string;
|
||||||
message: string
|
message: string;
|
||||||
data: T
|
data: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchListings() {
|
export async function fetchListings(page = 1, pageSize = 20) {
|
||||||
const { data } = await apiClient.get<ApiResponse<{ items: Listing[] }>>('/listings')
|
const { data } = await apiClient.get<ApiResponse<PaginatedResult<Listing>>>(
|
||||||
return data.data.items
|
"/listings",
|
||||||
|
{
|
||||||
|
params: { page, page_size: pageSize },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchListing(id: string | number) {
|
export async function fetchListing(id: string | number) {
|
||||||
const { data } = await apiClient.get<ApiResponse<Listing>>(`/listings/${id}`)
|
const { data } = await apiClient.get<ApiResponse<Listing>>(`/listings/${id}`);
|
||||||
return data.data
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSellerListings() {
|
export async function fetchSellerListings(page = 1, pageSize = 20) {
|
||||||
const { data } = await apiClient.get<ApiResponse<{ items: Listing[] }>>('/seller/listings')
|
const { data } = await apiClient.get<ApiResponse<PaginatedResult<Listing>>>(
|
||||||
return data.data.items
|
"/seller/listings",
|
||||||
|
{
|
||||||
|
params: { page, page_size: pageSize },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createListing(payload: ListingPayload) {
|
export async function createListing(payload: ListingPayload) {
|
||||||
const { data } = await apiClient.post<ApiResponse<Listing>>('/listings', payload)
|
const { data } = await apiClient.post<ApiResponse<Listing>>(
|
||||||
return data.data
|
"/listings",
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function submitListingReview(id: number) {
|
export async function submitListingReview(id: number) {
|
||||||
const { data } = await apiClient.post<ApiResponse<Listing>>(`/listings/${id}/submit-review`)
|
const { data } = await apiClient.post<ApiResponse<Listing>>(
|
||||||
return data.data
|
`/listings/${id}/submit-review`
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function offlineListing(id: number) {
|
export async function offlineListing(id: number) {
|
||||||
const { data } = await apiClient.delete<ApiResponse<{ offline: boolean }>>(`/listings/${id}`)
|
const { data } = await apiClient.delete<ApiResponse<{ offline: boolean }>>(
|
||||||
return data.data
|
`/listings/${id}`
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchPendingReviewListings() {
|
export async function fetchPendingReviewListings(page = 1, pageSize = 20) {
|
||||||
const { data } = await apiClient.get<ApiResponse<{ items: Listing[] }>>('/admin/listings/pending')
|
const { data } = await apiClient.get<ApiResponse<PaginatedResult<Listing>>>(
|
||||||
return data.data.items
|
"/admin/listings/pending",
|
||||||
|
{
|
||||||
|
params: { page, page_size: pageSize },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminListingQuery {
|
export interface AdminListingQuery {
|
||||||
owner_id?: string
|
owner_id?: string;
|
||||||
status?: string
|
status?: string;
|
||||||
review_status?: string
|
review_status?: string;
|
||||||
limit?: number
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAdminListings(query: AdminListingQuery = {}) {
|
export async function fetchAdminListings(query: AdminListingQuery = {}) {
|
||||||
const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined))
|
const params = Object.fromEntries(
|
||||||
const { data } = await apiClient.get<ApiResponse<{ items: Listing[] }>>('/admin/listings', { params })
|
Object.entries(query).filter(
|
||||||
return data.data.items
|
([, value]) => value !== "" && value !== undefined
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const { data } = await apiClient.get<ApiResponse<PaginatedResult<Listing>>>(
|
||||||
|
"/admin/listings",
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAdminListing(id: string | number) {
|
export async function fetchAdminListing(id: string | number) {
|
||||||
const { data } = await apiClient.get<ApiResponse<Listing>>(`/admin/listings/${id}`)
|
const { data } = await apiClient.get<ApiResponse<Listing>>(
|
||||||
return data.data
|
`/admin/listings/${id}`
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function adminOfflineListing(id: number, reason: string) {
|
export async function adminOfflineListing(id: number, reason: string) {
|
||||||
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/offline`, { reason })
|
const { data } = await apiClient.post<ApiResponse<Listing>>(
|
||||||
return data.data
|
`/admin/listings/${id}/offline`,
|
||||||
|
{ reason }
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function adminMarkListingAbnormal(id: number, reason: string) {
|
export async function adminMarkListingAbnormal(id: number, reason: string) {
|
||||||
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/mark-abnormal`, { reason })
|
const { data } = await apiClient.post<ApiResponse<Listing>>(
|
||||||
return data.data
|
`/admin/listings/${id}/mark-abnormal`,
|
||||||
|
{ reason }
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function approveListing(id: number) {
|
export async function approveListing(id: number) {
|
||||||
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/approve`)
|
const { data } = await apiClient.post<ApiResponse<Listing>>(
|
||||||
return data.data
|
`/admin/listings/${id}/approve`
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function rejectListing(id: number, reason: string) {
|
export async function rejectListing(id: number, reason: string) {
|
||||||
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/reject`, { reason })
|
const { data } = await apiClient.post<ApiResponse<Listing>>(
|
||||||
return data.data
|
`/admin/listings/${id}/reject`,
|
||||||
|
{ reason }
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { apiClient } from './client'
|
import { apiClient } from './client'
|
||||||
|
|
||||||
|
import { type PaginatedResult } from './orders'
|
||||||
|
|
||||||
export interface NotificationItem {
|
export interface NotificationItem {
|
||||||
id: number
|
id: number
|
||||||
user_id: number
|
user_id: number
|
||||||
@@ -18,9 +20,11 @@ interface ApiResponse<T> {
|
|||||||
data: T
|
data: T
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchNotifications() {
|
export async function fetchNotifications(page = 1, pageSize = 20) {
|
||||||
const { data } = await apiClient.get<ApiResponse<{ items: NotificationItem[] }>>('/notifications')
|
const { data } = await apiClient.get<ApiResponse<PaginatedResult<NotificationItem>>>('/notifications', {
|
||||||
return data.data.items
|
params: { page, page_size: pageSize },
|
||||||
|
})
|
||||||
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markNotificationRead(id: number) {
|
export async function markNotificationRead(id: number) {
|
||||||
|
|||||||
+185
-68
@@ -1,116 +1,233 @@
|
|||||||
import { apiClient } from './client'
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export interface OrderListItem {
|
||||||
|
id: number;
|
||||||
|
order_no: string;
|
||||||
|
listing_title: string;
|
||||||
|
game_name: string;
|
||||||
|
account_title: string;
|
||||||
|
renter_nickname: string;
|
||||||
|
owner_nickname: string;
|
||||||
|
status: string;
|
||||||
|
handoff_status: string;
|
||||||
|
settlement_status: string;
|
||||||
|
rent_amount: number;
|
||||||
|
deposit_amount: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Order {
|
export interface Order {
|
||||||
id: number
|
id: number;
|
||||||
order_no: string
|
order_no: string;
|
||||||
listing_id: number
|
listing_id: number;
|
||||||
account_id: number
|
account_id: number;
|
||||||
owner_id: number
|
owner_id: number;
|
||||||
renter_id: number
|
renter_id: number;
|
||||||
owner_phone?: string
|
owner_phone?: string;
|
||||||
renter_phone?: string
|
renter_phone?: string;
|
||||||
title: string
|
title: string;
|
||||||
server_region: string
|
server_region: string;
|
||||||
login_platform: string
|
login_platform: string;
|
||||||
rent_start_at?: string
|
rent_start_at?: string;
|
||||||
rent_end_at?: string
|
rent_end_at?: string;
|
||||||
rent_amount: number
|
rent_amount: number;
|
||||||
deposit_amount: number
|
deposit_amount: number;
|
||||||
platform_fee: number
|
platform_fee: number;
|
||||||
account_snapshot?: Record<string, unknown>
|
account_snapshot?: Record<string, unknown>;
|
||||||
status: string
|
status: string;
|
||||||
handoff_status: string
|
handoff_status: string;
|
||||||
settlement_status: string
|
settlement_status: string;
|
||||||
created_at: string
|
checkout?: Checkout;
|
||||||
updated_at: string
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Checkout {
|
||||||
|
id: number;
|
||||||
|
order_id: number;
|
||||||
|
initiated_by: number;
|
||||||
|
status: string;
|
||||||
|
rent_amount: number;
|
||||||
|
deposit_amount: number;
|
||||||
|
consumable_amount: number;
|
||||||
|
coin_consumed_m: number;
|
||||||
|
other_amount: number;
|
||||||
|
deposit_deduct_amount: number;
|
||||||
|
renter_refund_amount: number;
|
||||||
|
owner_income_amount: number;
|
||||||
|
content: string;
|
||||||
|
evidence_urls: string[];
|
||||||
|
owner_adjustment_reason: string;
|
||||||
|
owner_adjusted_at?: string;
|
||||||
|
renter_confirmed_at?: string;
|
||||||
|
renter_rejected_at?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HandoffRecord {
|
export interface HandoffRecord {
|
||||||
id: number
|
id: number;
|
||||||
order_id: number
|
order_id: number;
|
||||||
from_user_id: number
|
from_user_id: number;
|
||||||
to_user_id: number
|
to_user_id: number;
|
||||||
type: string
|
type: string;
|
||||||
content: string
|
content: string;
|
||||||
confirmed_by_renter_at?: string
|
confirmed_by_renter_at?: string;
|
||||||
confirmed_by_owner_at?: string
|
confirmed_by_owner_at?: string;
|
||||||
created_at: string
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmitCheckoutPayload {
|
||||||
|
content: string;
|
||||||
|
consumable_amount: number;
|
||||||
|
coin_consumed_m: number;
|
||||||
|
other_amount: number;
|
||||||
|
evidence_urls: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CounterCheckoutPayload {
|
||||||
|
consumable_amount: number;
|
||||||
|
coin_consumed_m: number;
|
||||||
|
other_amount: number;
|
||||||
|
deposit_deduct_amount: number;
|
||||||
|
reason: string;
|
||||||
|
evidence_urls: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResult<T> {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
code: string
|
code: string;
|
||||||
message: string
|
message: string;
|
||||||
data: T
|
data: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createOrder(listingId: number) {
|
export async function createOrder(listingId: number) {
|
||||||
const { data } = await apiClient.post<ApiResponse<Order>>('/orders', {
|
const { data } = await apiClient.post<ApiResponse<Order>>("/orders", {
|
||||||
listing_id: listingId,
|
listing_id: listingId,
|
||||||
})
|
});
|
||||||
return data.data
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchOrders() {
|
export async function fetchOrders(page = 1, pageSize = 20) {
|
||||||
const { data } = await apiClient.get<ApiResponse<{ items: Order[] }>>('/orders')
|
const { data } = await apiClient.get<
|
||||||
return data.data.items
|
ApiResponse<PaginatedResult<OrderListItem>>
|
||||||
|
>("/orders", {
|
||||||
|
params: { page, page_size: pageSize },
|
||||||
|
});
|
||||||
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAdminOrders() {
|
export async function fetchAdminOrders(page = 1, pageSize = 20) {
|
||||||
const { data } = await apiClient.get<ApiResponse<{ items: Order[] }>>('/admin/orders')
|
const { data } = await apiClient.get<
|
||||||
return data.data.items
|
ApiResponse<PaginatedResult<OrderListItem>>
|
||||||
|
>("/admin/orders", {
|
||||||
|
params: { page, page_size: pageSize },
|
||||||
|
});
|
||||||
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
export async function fetchAdminOrder(id: string | number) {
|
||||||
const { data } = await apiClient.get<ApiResponse<Order>>(`/admin/orders/${id}`)
|
const { data } = await apiClient.get<ApiResponse<Order>>(
|
||||||
return data.data
|
`/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 }>>(
|
||||||
return data.data
|
`/orders/${id}/cancel`
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function submitHandoff(id: number, content: string) {
|
export async function submitHandoff(id: number, content: string) {
|
||||||
const { data } = await apiClient.post<ApiResponse<HandoffRecord>>(`/orders/${id}/handoff`, { content })
|
const { data } = await apiClient.post<ApiResponse<HandoffRecord>>(
|
||||||
return data.data
|
`/orders/${id}/handoff`,
|
||||||
|
{ content }
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchHandoffRecords(id: string | number) {
|
export async function fetchHandoffRecords(id: string | number) {
|
||||||
const { data } = await apiClient.get<ApiResponse<{ items: HandoffRecord[] }>>(`/orders/${id}/handoff-records`)
|
const { data } = await apiClient.get<ApiResponse<{ items: HandoffRecord[] }>>(
|
||||||
return data.data.items
|
`/orders/${id}/handoff-records`
|
||||||
|
);
|
||||||
|
return data.data.items;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAdminHandoffRecords(id: string | number) {
|
export async function fetchAdminHandoffRecords(id: string | number) {
|
||||||
const { data } = await apiClient.get<ApiResponse<{ items: HandoffRecord[] }>>(`/admin/orders/${id}/handoff-records`)
|
const { data } = await apiClient.get<ApiResponse<{ items: HandoffRecord[] }>>(
|
||||||
return data.data.items
|
`/admin/orders/${id}/handoff-records`
|
||||||
|
);
|
||||||
|
return data.data.items;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function adminCloseOrder(id: number, reason: string) {
|
export async function adminCloseOrder(id: number, reason: string) {
|
||||||
const { data } = await apiClient.post<ApiResponse<{ closed: boolean }>>(`/admin/orders/${id}/close`, { reason })
|
const { data } = await apiClient.post<ApiResponse<{ closed: boolean }>>(
|
||||||
return data.data
|
`/admin/orders/${id}/close`,
|
||||||
|
{ reason }
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function adminMarkOrderAbnormal(id: number, reason: string) {
|
export async function adminMarkOrderAbnormal(id: number, reason: string) {
|
||||||
const { data } = await apiClient.post<ApiResponse<{ abnormal: boolean }>>(`/admin/orders/${id}/mark-abnormal`, { reason })
|
const { data } = await apiClient.post<ApiResponse<{ abnormal: boolean }>>(
|
||||||
return data.data
|
`/admin/orders/${id}/mark-abnormal`,
|
||||||
|
{ reason }
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 }>>(
|
||||||
return data.data
|
`/orders/${id}/confirm-receive`
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function submitReturn(id: number, content: string) {
|
export async function submitCheckout(
|
||||||
const { data } = await apiClient.post<ApiResponse<HandoffRecord>>(`/orders/${id}/return`, { content })
|
id: number,
|
||||||
return data.data
|
payload: SubmitCheckoutPayload
|
||||||
|
) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<HandoffRecord>>(
|
||||||
|
`/orders/${id}/checkout`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function confirmReturn(id: number) {
|
export async function confirmCheckout(id: number) {
|
||||||
const { data } = await apiClient.post<ApiResponse<{ completed: boolean }>>(`/orders/${id}/confirm-return`)
|
const { data } = await apiClient.post<ApiResponse<{ completed: boolean }>>(
|
||||||
return data.data
|
`/orders/${id}/checkout/confirm`
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function counterCheckout(
|
||||||
|
id: number,
|
||||||
|
payload: CounterCheckoutPayload
|
||||||
|
) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<Checkout>>(
|
||||||
|
`/orders/${id}/checkout/counter`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acceptCheckout(id: number) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<{ completed: boolean }>>(
|
||||||
|
`/orders/${id}/checkout/accept`
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { apiClient } from './client'
|
import { apiClient } from './client'
|
||||||
|
|
||||||
|
import { type PaginatedResult } from './orders'
|
||||||
|
|
||||||
export interface WalletAccount {
|
export interface WalletAccount {
|
||||||
user_id: number
|
user_id: number
|
||||||
available_balance: number
|
available_balance: number
|
||||||
@@ -33,7 +35,9 @@ export async function fetchWalletBalance() {
|
|||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchWalletLedger() {
|
export async function fetchWalletLedger(page = 1, pageSize = 20) {
|
||||||
const { data } = await apiClient.get<ApiResponse<{ items: WalletLedger[] }>>('/wallet/ledger')
|
const { data } = await apiClient.get<ApiResponse<PaginatedResult<WalletLedger>>>('/wallet/ledger', {
|
||||||
return data.data.items
|
params: { page, page_size: pageSize },
|
||||||
|
})
|
||||||
|
return data.data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { DataLine, Document, DocumentChecked, Operation, ScaleToOriginal, Shop, SwitchButton, Tickets, User, Wallet } from '@element-plus/icons-vue'
|
import {
|
||||||
|
DataLine,
|
||||||
|
Document,
|
||||||
|
DocumentChecked,
|
||||||
|
Expand,
|
||||||
|
Fold,
|
||||||
|
Operation,
|
||||||
|
ScaleToOriginal,
|
||||||
|
Shop,
|
||||||
|
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, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { logoutAdmin } from '@/api/adminAuth'
|
import { logoutAdmin } from '@/api/adminAuth'
|
||||||
import { useAdminSessionStore } from '@/stores/adminSession'
|
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const adminSession = useAdminSessionStore()
|
const adminSession = useAdminSessionStore()
|
||||||
|
const collapsed = ref(localStorage.getItem('admin_sidebar_collapsed') === '1')
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ label: '仪表盘', to: '/admin/dashboard', icon: DataLine },
|
{ label: '仪表盘', to: '/admin/dashboard', icon: DataLine },
|
||||||
@@ -23,6 +38,18 @@ const navItems = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const adminName = computed(() => adminSession.nickname || adminSession.username || '管理员')
|
const adminName = computed(() => adminSession.nickname || adminSession.username || '管理员')
|
||||||
|
const activeMenu = computed(() => {
|
||||||
|
const path = route.path
|
||||||
|
const matched = [...navItems]
|
||||||
|
.sort((a, b) => b.to.length - a.to.length)
|
||||||
|
.find((item) => path === item.to || path.startsWith(`${item.to}/`))
|
||||||
|
return matched?.to || '/admin/dashboard'
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleCollapse() {
|
||||||
|
collapsed.value = !collapsed.value
|
||||||
|
localStorage.setItem('admin_sidebar_collapsed', collapsed.value ? '1' : '0')
|
||||||
|
}
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
try {
|
try {
|
||||||
@@ -37,28 +64,51 @@ async function handleLogout() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="admin-shell">
|
<div class="admin-shell" :class="{ 'is-sidebar-collapsed': collapsed }">
|
||||||
<aside class="admin-sidebar">
|
<aside class="admin-sidebar">
|
||||||
<RouterLink class="admin-brand" to="/admin/dashboard">
|
<div class="admin-sidebar-head">
|
||||||
<span class="brand-mark">H</span>
|
<RouterLink class="admin-brand" to="/admin/dashboard">
|
||||||
<span>哈夫币后台</span>
|
<span class="brand-mark">H</span>
|
||||||
</RouterLink>
|
<span class="admin-brand-text">哈夫币后台</span>
|
||||||
|
|
||||||
<nav class="admin-nav">
|
|
||||||
<RouterLink v-for="item in navItems" :key="item.to" class="admin-nav-link" :to="item.to">
|
|
||||||
<el-icon><component :is="item.icon" /></el-icon>
|
|
||||||
<span>{{ item.label }}</span>
|
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</nav>
|
</div>
|
||||||
|
|
||||||
|
<el-menu
|
||||||
|
class="admin-menu"
|
||||||
|
:collapse="collapsed"
|
||||||
|
:collapse-transition="false"
|
||||||
|
:default-active="activeMenu"
|
||||||
|
router
|
||||||
|
background-color="#111827"
|
||||||
|
text-color="#b7c3d2"
|
||||||
|
active-text-color="#ffffff"
|
||||||
|
>
|
||||||
|
<el-menu-item v-for="item in navItems" :key="item.to" :index="item.to">
|
||||||
|
<el-icon><component :is="item.icon" /></el-icon>
|
||||||
|
<template #title>{{ item.label }}</template>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-menu>
|
||||||
|
|
||||||
|
<div class="admin-sidebar-footer">
|
||||||
|
<el-tooltip :content="collapsed ? '退出后台' : ''" placement="right" :disabled="!collapsed">
|
||||||
|
<el-button class="admin-logout-btn" :icon="SwitchButton" @click="handleLogout">
|
||||||
|
<span v-if="!collapsed">退出后台</span>
|
||||||
|
</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="admin-workspace">
|
<section class="admin-workspace">
|
||||||
<header class="admin-topbar">
|
<header class="admin-topbar">
|
||||||
<div>
|
<div class="admin-topbar-left">
|
||||||
<span>管理系统</span>
|
<el-tooltip :content="collapsed ? '展开导航' : '折叠导航'" placement="bottom">
|
||||||
<strong>{{ adminName }}</strong>
|
<el-button class="admin-topbar-collapse" :icon="collapsed ? Expand : Fold" @click="toggleCollapse" />
|
||||||
|
</el-tooltip>
|
||||||
|
<div>
|
||||||
|
<span>管理系统</span>
|
||||||
|
<strong>{{ adminName }}</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-button :icon="SwitchButton" @click="handleLogout">退出</el-button>
|
|
||||||
</header>
|
</header>
|
||||||
<main class="admin-main">
|
<main class="admin-main">
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -92,51 +92,61 @@ const router = createRouter({
|
|||||||
path: "/orders/create",
|
path: "/orders/create",
|
||||||
name: "order-create",
|
name: "order-create",
|
||||||
component: () => import("@/views/account/OrderCreateView.vue"),
|
component: () => import("@/views/account/OrderCreateView.vue"),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/orders",
|
path: "/orders",
|
||||||
name: "orders",
|
name: "orders",
|
||||||
component: () => import("@/views/account/OrdersView.vue"),
|
component: () => import("@/views/account/OrdersView.vue"),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/orders/:id",
|
path: "/orders/:id",
|
||||||
name: "order-detail",
|
name: "order-detail",
|
||||||
component: () => import("@/views/account/OrderDetailView.vue"),
|
component: () => import("@/views/account/OrderDetailView.vue"),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/wallet",
|
path: "/wallet",
|
||||||
name: "wallet",
|
name: "wallet",
|
||||||
component: () => import("@/views/account/WalletView.vue"),
|
component: () => import("@/views/account/WalletView.vue"),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/notifications",
|
path: "/notifications",
|
||||||
name: "notifications",
|
name: "notifications",
|
||||||
component: () => import("@/views/account/NotificationsView.vue"),
|
component: () => import("@/views/account/NotificationsView.vue"),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/realname",
|
path: "/realname",
|
||||||
name: "realname",
|
name: "realname",
|
||||||
component: () => import("@/views/account/RealnameView.vue"),
|
component: () => import("@/views/account/RealnameView.vue"),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/seller/listings",
|
path: "/seller/listings",
|
||||||
name: "seller-listings",
|
name: "seller-listings",
|
||||||
component: () => import("@/views/seller/SellerListingsView.vue"),
|
component: () => import("@/views/seller/SellerListingsView.vue"),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/seller/listings/create",
|
path: "/seller/listings/create",
|
||||||
name: "seller-listing-create",
|
name: "seller-listing-create",
|
||||||
component: () => import("@/views/seller/SellerListingCreateView.vue"),
|
component: () => import("@/views/seller/SellerListingCreateView.vue"),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/seller/handoffs",
|
path: "/seller/handoffs",
|
||||||
name: "seller-handoffs",
|
name: "seller-handoffs",
|
||||||
component: () => import("@/views/seller/SellerHandoffsView.vue"),
|
component: () => import("@/views/seller/SellerHandoffsView.vue"),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/seller/earnings",
|
path: "/seller/earnings",
|
||||||
name: "seller-earnings",
|
name: "seller-earnings",
|
||||||
component: () => import("@/views/seller/SellerEarningsView.vue"),
|
component: () => import("@/views/seller/SellerEarningsView.vue"),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/admin/login",
|
path: "/admin/login",
|
||||||
@@ -220,14 +230,17 @@ router.beforeEach(async (to) => {
|
|||||||
|
|
||||||
if (to.meta.requiresAuth) {
|
if (to.meta.requiresAuth) {
|
||||||
const session = useSessionStore();
|
const session = useSessionStore();
|
||||||
if (!session.token) {
|
const hasToken = !!localStorage.getItem("access_token");
|
||||||
return { path: "/m/login", query: { redirect: to.fullPath } };
|
if (!hasToken) {
|
||||||
|
const loginPath = to.path.startsWith("/m") ? "/m/login" : "/login";
|
||||||
|
return { path: loginPath, query: { redirect: to.fullPath } };
|
||||||
}
|
}
|
||||||
if (!session.phone) {
|
if (!session.phone) {
|
||||||
try {
|
try {
|
||||||
await session.loadMe();
|
await session.loadMe();
|
||||||
} catch {
|
} catch {
|
||||||
return { path: "/m/login", query: { redirect: to.fullPath } };
|
const loginPath = to.path.startsWith("/m") ? "/m/login" : "/login";
|
||||||
|
return { path: loginPath, query: { redirect: to.fullPath } };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+260
-37
@@ -82,19 +82,35 @@ a {
|
|||||||
|
|
||||||
.admin-shell {
|
.admin-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
grid-template-columns: 260px 1fr;
|
grid-template-columns: 220px 1fr;
|
||||||
background: #f5f7fb;
|
background: #f5f7fb;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: grid-template-columns 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell.is-sidebar-collapsed {
|
||||||
|
grid-template-columns: 68px 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-sidebar {
|
.admin-sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border-right: 1px solid #dfe5ec;
|
border-right: 1px solid #dfe5ec;
|
||||||
background: #111827;
|
background: #111827;
|
||||||
padding: 20px 16px;
|
padding: 20px 12px;
|
||||||
color: #dbe4ee;
|
color: #dbe4ee;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-brand {
|
.admin-brand {
|
||||||
@@ -102,38 +118,164 @@ a {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
|
min-width: 0;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-nav {
|
.admin-brand-text {
|
||||||
display: grid;
|
overflow: hidden;
|
||||||
gap: 6px;
|
white-space: nowrap;
|
||||||
margin-top: 28px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-nav-link {
|
.admin-menu {
|
||||||
display: flex;
|
flex: 1;
|
||||||
align-items: center;
|
min-width: 0;
|
||||||
gap: 10px;
|
margin-top: 26px;
|
||||||
min-height: 42px;
|
border-right: 0;
|
||||||
|
overflow: hidden auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-menu:not(.el-menu--collapse) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-menu.el-menu--collapse {
|
||||||
|
width: 44px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-menu .el-menu-item {
|
||||||
|
height: 40px;
|
||||||
|
margin: 0 0 6px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 0 12px;
|
padding: 0 10px;
|
||||||
color: #b7c3d2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-nav-link.router-link-active {
|
|
||||||
background: #0f766e;
|
|
||||||
color: #ffffff;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-menu.el-menu--collapse .el-menu-item {
|
||||||
|
width: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
padding: 0 !important;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-menu .el-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-menu.el-menu--collapse .el-icon {
|
||||||
|
margin-right: 0;
|
||||||
|
font-size: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-sidebar-collapsed .admin-menu,
|
||||||
|
.is-sidebar-collapsed .admin-menu.el-menu--collapse {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-sidebar-collapsed .admin-menu {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-sidebar-collapsed .admin-menu .el-menu-item,
|
||||||
|
.is-sidebar-collapsed .admin-menu.el-menu--collapse .el-menu-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px !important;
|
||||||
|
min-width: 44px !important;
|
||||||
|
max-width: 44px;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-sidebar-collapsed .admin-menu .el-menu-tooltip__trigger,
|
||||||
|
.is-sidebar-collapsed .admin-menu .el-tooltip__trigger {
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-sidebar-collapsed .admin-menu .el-icon {
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-menu .el-menu-item.is-active {
|
||||||
|
background: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-menu .el-menu-item:hover {
|
||||||
|
background: rgba(15, 118, 110, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar-footer {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-logout-btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
border-color: rgba(183, 195, 210, 0.28);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: #dbe4ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-logout-btn:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.32);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-sidebar-collapsed .admin-sidebar {
|
||||||
|
align-items: center;
|
||||||
|
padding: 22px 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-sidebar-collapsed .admin-sidebar-head {
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-sidebar-collapsed .admin-brand {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-sidebar-collapsed .brand-mark {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 13px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-sidebar-collapsed .admin-brand-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-sidebar-collapsed .admin-logout-btn {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-workspace {
|
.admin-workspace {
|
||||||
|
height: 100vh;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-topbar {
|
.admin-topbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 8;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -143,6 +285,19 @@ a {
|
|||||||
padding: 0 28px;
|
padding: 0 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-topbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-topbar-collapse {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-topbar span {
|
.admin-topbar span {
|
||||||
display: block;
|
display: block;
|
||||||
color: #6b7785;
|
color: #6b7785;
|
||||||
@@ -464,6 +619,7 @@ h1 {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
column-gap: 16px;
|
column-gap: 16px;
|
||||||
|
row-gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-panel {
|
.order-panel {
|
||||||
@@ -587,6 +743,17 @@ h1 {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-tag-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tag-row.compact {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.code-panel {
|
.code-panel {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
@@ -1274,50 +1441,103 @@ h1 {
|
|||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
.admin-shell {
|
.admin-shell {
|
||||||
grid-template-columns: 220px 1fr;
|
grid-template-columns: 208px 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell.is-sidebar-collapsed {
|
||||||
|
grid-template-columns: 68px 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-sidebar {
|
.admin-sidebar {
|
||||||
padding: 18px 12px;
|
padding: 18px 10px;
|
||||||
}
|
|
||||||
|
|
||||||
.admin-nav-link {
|
|
||||||
padding: 0 10px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.admin-shell {
|
.admin-shell,
|
||||||
|
.admin-shell.is-sidebar-collapsed {
|
||||||
|
height: auto;
|
||||||
|
min-height: 100vh;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-sidebar {
|
.admin-sidebar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
height: auto;
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
border-bottom: 1px solid #1f2937;
|
border-bottom: 1px solid #1f2937;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-workspace {
|
||||||
|
height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell.is-sidebar-collapsed .admin-sidebar {
|
||||||
|
align-items: stretch;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell.is-sidebar-collapsed .admin-sidebar-head {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell.is-sidebar-collapsed .admin-brand {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell.is-sidebar-collapsed .admin-brand-text {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell.is-sidebar-collapsed .brand-mark {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-brand {
|
.admin-brand {
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-nav {
|
.admin-menu,
|
||||||
grid-auto-flow: column;
|
.admin-menu.el-menu--collapse {
|
||||||
grid-auto-columns: max-content;
|
display: flex;
|
||||||
gap: 8px;
|
width: 100%;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding-bottom: 2px;
|
padding-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-nav-link {
|
.admin-menu .el-menu-item,
|
||||||
min-height: 38px;
|
.admin-menu.el-menu--collapse .el-menu-item {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
justify-content: flex-start;
|
||||||
|
height: 38px;
|
||||||
|
margin: 0 8px 0 0;
|
||||||
|
padding: 0 12px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-sidebar-footer {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-logout-btn,
|
||||||
|
.is-sidebar-collapsed .admin-logout-btn {
|
||||||
|
width: auto;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-topbar {
|
.admin-topbar {
|
||||||
min-height: 58px;
|
min-height: 58px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
@@ -1381,10 +1601,6 @@ h1 {
|
|||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-topbar .el-button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-panels {
|
.dashboard-panels {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -1410,3 +1626,10 @@ h1 {
|
|||||||
background: var(--van-toast-background) !important;
|
background: var(--van-toast-background) !important;
|
||||||
color: var(--van-toast-text-color) !important;
|
color: var(--van-toast-text-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ const orderStatusMap: Record<string, string> = {
|
|||||||
pending_handoff: '待交接',
|
pending_handoff: '待交接',
|
||||||
renting: '使用中',
|
renting: '使用中',
|
||||||
overdue: '已逾期',
|
overdue: '已逾期',
|
||||||
pending_return_confirm: '待归还确认',
|
pending_return_confirm: '待结账确认',
|
||||||
|
pending_checkout_confirm: '待号主确认结账',
|
||||||
|
pending_checkout_accept: '待租客确认修正',
|
||||||
|
checkout_disputing: '结账争议中',
|
||||||
completed: '已完成',
|
completed: '已完成',
|
||||||
cancelled: '已取消',
|
cancelled: '已取消',
|
||||||
closed: '已关闭',
|
closed: '已关闭',
|
||||||
@@ -30,13 +33,17 @@ const handoffStatusMap: Record<string, string> = {
|
|||||||
pending_owner: '待号主交接',
|
pending_owner: '待号主交接',
|
||||||
pending_renter_confirm: '待租客确认',
|
pending_renter_confirm: '待租客确认',
|
||||||
received: '已确认收号',
|
received: '已确认收号',
|
||||||
pending_owner_return_confirm: '待号主确认归还',
|
pending_owner_return_confirm: '待号主确认结账',
|
||||||
|
pending_owner_checkout: '待号主确认结账',
|
||||||
|
pending_renter_checkout: '待租客确认修正',
|
||||||
|
checkout_disputed: '结账争议中',
|
||||||
returned: '已归还',
|
returned: '已归还',
|
||||||
cancelled: '已取消',
|
cancelled: '已取消',
|
||||||
owner_timeout: '号主交接超时',
|
owner_timeout: '号主交接超时',
|
||||||
renter_confirm_timeout: '租客确认超时',
|
renter_confirm_timeout: '租客确认超时',
|
||||||
return_overdue: '归还逾期',
|
return_overdue: '归还逾期',
|
||||||
owner_return_confirm_timeout: '号主确认归还超时',
|
owner_return_confirm_timeout: '号主确认结账超时',
|
||||||
|
owner_checkout_confirm_timeout: '号主确认结账超时',
|
||||||
admin_closed: '客服关闭',
|
admin_closed: '客服关闭',
|
||||||
admin_abnormal: '客服标记异常',
|
admin_abnormal: '客服标记异常',
|
||||||
arbitrated: '已仲裁',
|
arbitrated: '已仲裁',
|
||||||
@@ -50,6 +57,7 @@ const settlementStatusMap: Record<string, string> = {
|
|||||||
refunded: '已退款',
|
refunded: '已退款',
|
||||||
cancelled: '已取消',
|
cancelled: '已取消',
|
||||||
closed: '已关闭',
|
closed: '已关闭',
|
||||||
|
disputed: '争议中',
|
||||||
arbitrated: '已仲裁',
|
arbitrated: '已仲裁',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,18 +7,28 @@ import { formatDateTime } from '@/utils/time'
|
|||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const notifications = ref<NotificationItem[]>([])
|
const notifications = ref<NotificationItem[]>([])
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const currentPageSize = ref(20)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
onMounted(loadNotifications)
|
onMounted(loadNotifications)
|
||||||
|
|
||||||
async function loadNotifications() {
|
async function loadNotifications() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
notifications.value = await fetchNotifications()
|
const result = await fetchNotifications(currentPage.value, currentPageSize.value)
|
||||||
|
notifications.value = result.items
|
||||||
|
total.value = result.total
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSizeChange() {
|
||||||
|
currentPage.value = 1
|
||||||
|
loadNotifications()
|
||||||
|
}
|
||||||
|
|
||||||
async function markRead(id: number) {
|
async function markRead(id: number) {
|
||||||
await markNotificationRead(id)
|
await markNotificationRead(id)
|
||||||
ElMessage.success('已标记为已读')
|
ElMessage.success('已标记为已读')
|
||||||
@@ -51,5 +61,17 @@ async function markRead(id: number) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination-wrap" v-if="total > 0">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="currentPageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@current-change="loadNotifications"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -6,13 +6,15 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { createDispute } from '@/api/disputes'
|
import { createDispute } from '@/api/disputes'
|
||||||
import { uploadFile } from '@/api/files'
|
import { uploadFile } from '@/api/files'
|
||||||
import {
|
import {
|
||||||
|
acceptCheckout,
|
||||||
cancelOrder,
|
cancelOrder,
|
||||||
|
confirmCheckout,
|
||||||
confirmReceive,
|
confirmReceive,
|
||||||
confirmReturn,
|
counterCheckout,
|
||||||
fetchHandoffRecords,
|
fetchHandoffRecords,
|
||||||
fetchOrder,
|
fetchOrder,
|
||||||
|
submitCheckout,
|
||||||
submitHandoff,
|
submitHandoff,
|
||||||
submitReturn,
|
|
||||||
type HandoffRecord,
|
type HandoffRecord,
|
||||||
type Order,
|
type Order,
|
||||||
} from '@/api/orders'
|
} from '@/api/orders'
|
||||||
@@ -29,12 +31,30 @@ const handoffing = ref(false)
|
|||||||
const confirming = ref(false)
|
const confirming = ref(false)
|
||||||
const returning = ref(false)
|
const returning = ref(false)
|
||||||
const completing = ref(false)
|
const completing = ref(false)
|
||||||
|
const countering = ref(false)
|
||||||
|
const acceptingCheckout = ref(false)
|
||||||
|
const rejectingCheckout = ref(false)
|
||||||
const disputing = ref(false)
|
const disputing = ref(false)
|
||||||
const uploadingEvidence = ref(false)
|
const uploadingEvidence = ref(false)
|
||||||
const order = ref<Order | null>(null)
|
const order = ref<Order | null>(null)
|
||||||
const handoffRecords = ref<HandoffRecord[]>([])
|
const handoffRecords = ref<HandoffRecord[]>([])
|
||||||
const handoffContent = ref('')
|
const handoffContent = ref('')
|
||||||
const returnContent = ref('')
|
const checkoutForm = ref({
|
||||||
|
content: '',
|
||||||
|
consumable_amount: 0,
|
||||||
|
coin_consumed_m: 0,
|
||||||
|
other_amount: 0,
|
||||||
|
evidenceText: '',
|
||||||
|
})
|
||||||
|
const counterForm = ref({
|
||||||
|
consumable_amount: 0,
|
||||||
|
coin_consumed_m: 0,
|
||||||
|
other_amount: 0,
|
||||||
|
deposit_deduct_amount: 0,
|
||||||
|
reason: '',
|
||||||
|
evidenceText: '',
|
||||||
|
})
|
||||||
|
const rejectReason = ref('')
|
||||||
const disputeType = ref('cannot_login')
|
const disputeType = ref('cannot_login')
|
||||||
const disputeDescription = ref('')
|
const disputeDescription = ref('')
|
||||||
const disputeEvidenceText = ref('')
|
const disputeEvidenceText = ref('')
|
||||||
@@ -43,7 +63,10 @@ const isOwner = computed(() => order.value?.owner_id === session.userId)
|
|||||||
const isRenter = computed(() => order.value?.renter_id === session.userId)
|
const isRenter = computed(() => order.value?.renter_id === session.userId)
|
||||||
const canOpenDispute = computed(() => {
|
const canOpenDispute = computed(() => {
|
||||||
if (!order.value || (!isOwner.value && !isRenter.value)) return false
|
if (!order.value || (!isOwner.value && !isRenter.value)) return false
|
||||||
return !['completed', 'cancelled', 'closed', 'disputing'].includes(order.value.status)
|
return !['completed', 'cancelled', 'closed', 'disputing', 'checkout_disputing', 'abnormal'].includes(order.value.status)
|
||||||
|
})
|
||||||
|
const isCheckoutDisputeStage = computed(() => {
|
||||||
|
return !!order.value && ['pending_checkout_confirm', 'pending_checkout_accept'].includes(order.value.status)
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(loadOrder)
|
onMounted(loadOrder)
|
||||||
@@ -53,6 +76,7 @@ async function loadOrder() {
|
|||||||
try {
|
try {
|
||||||
order.value = await fetchOrder(String(route.params.id))
|
order.value = await fetchOrder(String(route.params.id))
|
||||||
handoffRecords.value = await fetchHandoffRecords(String(route.params.id))
|
handoffRecords.value = await fetchHandoffRecords(String(route.params.id))
|
||||||
|
hydrateCounterForm()
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -101,50 +125,114 @@ async function handleConfirmReceive() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmitReturn() {
|
async function handleSubmitCheckout() {
|
||||||
if (!order.value) return
|
if (!order.value) return
|
||||||
returning.value = true
|
returning.value = true
|
||||||
try {
|
try {
|
||||||
await submitReturn(order.value.id, returnContent.value)
|
await submitCheckout(order.value.id, {
|
||||||
returnContent.value = ''
|
content: checkoutForm.value.content,
|
||||||
ElMessage.success('归还申请已提交,等待号主确认')
|
consumable_amount: checkoutForm.value.consumable_amount,
|
||||||
|
coin_consumed_m: checkoutForm.value.coin_consumed_m,
|
||||||
|
other_amount: checkoutForm.value.other_amount,
|
||||||
|
evidence_urls: linesToList(checkoutForm.value.evidenceText),
|
||||||
|
})
|
||||||
|
checkoutForm.value = {
|
||||||
|
content: '',
|
||||||
|
consumable_amount: 0,
|
||||||
|
coin_consumed_m: 0,
|
||||||
|
other_amount: 0,
|
||||||
|
evidenceText: '',
|
||||||
|
}
|
||||||
|
ElMessage.success('结账已发起,等待号主确认')
|
||||||
await loadOrder()
|
await loadOrder()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(readError(error, '提交归还失败'))
|
ElMessage.error(readError(error, '发起结账失败'))
|
||||||
} finally {
|
} finally {
|
||||||
returning.value = false
|
returning.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleConfirmReturn() {
|
async function handleConfirmCheckout() {
|
||||||
if (!order.value) return
|
if (!order.value) return
|
||||||
completing.value = true
|
completing.value = true
|
||||||
try {
|
try {
|
||||||
await confirmReturn(order.value.id)
|
await confirmCheckout(order.value.id)
|
||||||
ElMessage.success('归还已确认,订单完成')
|
ElMessage.success('结账已确认,订单完成')
|
||||||
await loadOrder()
|
await loadOrder()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(readError(error, '确认归还失败'))
|
ElMessage.error(readError(error, '确认结账失败'))
|
||||||
} finally {
|
} finally {
|
||||||
completing.value = false
|
completing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCounterCheckout() {
|
||||||
|
if (!order.value) return
|
||||||
|
countering.value = true
|
||||||
|
try {
|
||||||
|
await counterCheckout(order.value.id, {
|
||||||
|
consumable_amount: counterForm.value.consumable_amount,
|
||||||
|
coin_consumed_m: counterForm.value.coin_consumed_m,
|
||||||
|
other_amount: counterForm.value.other_amount,
|
||||||
|
deposit_deduct_amount: counterForm.value.deposit_deduct_amount,
|
||||||
|
reason: counterForm.value.reason,
|
||||||
|
evidence_urls: linesToList(counterForm.value.evidenceText),
|
||||||
|
})
|
||||||
|
ElMessage.success('结账修正已提交,等待租客确认')
|
||||||
|
await loadOrder()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '修改结账失败'))
|
||||||
|
} finally {
|
||||||
|
countering.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAcceptCheckout() {
|
||||||
|
if (!order.value) return
|
||||||
|
acceptingCheckout.value = true
|
||||||
|
try {
|
||||||
|
await acceptCheckout(order.value.id)
|
||||||
|
ElMessage.success('已确认修正结账,订单完成')
|
||||||
|
await loadOrder()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '确认修正失败'))
|
||||||
|
} finally {
|
||||||
|
acceptingCheckout.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRejectCheckout() {
|
||||||
|
if (!order.value) return
|
||||||
|
rejectingCheckout.value = true
|
||||||
|
try {
|
||||||
|
await createDispute(order.value.id, {
|
||||||
|
type: 'checkout_dispute',
|
||||||
|
description: rejectReason.value,
|
||||||
|
evidence_urls: [],
|
||||||
|
})
|
||||||
|
rejectReason.value = ''
|
||||||
|
ElMessage.success('已拒绝修正结账,订单进入争议处理')
|
||||||
|
await loadOrder()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '拒绝修正失败'))
|
||||||
|
} finally {
|
||||||
|
rejectingCheckout.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCreateDispute() {
|
async function handleCreateDispute() {
|
||||||
if (!order.value) return
|
if (!order.value) return
|
||||||
disputing.value = true
|
disputing.value = true
|
||||||
try {
|
try {
|
||||||
const evidence_urls = disputeEvidenceText.value
|
const evidence_urls = linesToList(disputeEvidenceText.value)
|
||||||
.split('\n')
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
await createDispute(order.value.id, {
|
await createDispute(order.value.id, {
|
||||||
type: disputeType.value,
|
type: isCheckoutDisputeStage.value ? 'checkout_dispute' : disputeType.value,
|
||||||
description: disputeDescription.value,
|
description: disputeDescription.value,
|
||||||
evidence_urls,
|
evidence_urls,
|
||||||
})
|
})
|
||||||
disputeDescription.value = ''
|
disputeDescription.value = ''
|
||||||
disputeEvidenceText.value = ''
|
disputeEvidenceText.value = ''
|
||||||
|
disputeType.value = 'cannot_login'
|
||||||
ElMessage.success('申诉已提交,订单进入仲裁处理')
|
ElMessage.success('申诉已提交,订单进入仲裁处理')
|
||||||
await loadOrder()
|
await loadOrder()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -171,6 +259,23 @@ async function handleEvidenceUpload(event: Event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hydrateCounterForm() {
|
||||||
|
if (!order.value?.checkout) return
|
||||||
|
const checkout = order.value.checkout
|
||||||
|
counterForm.value.consumable_amount = checkout.consumable_amount
|
||||||
|
counterForm.value.coin_consumed_m = checkout.coin_consumed_m
|
||||||
|
counterForm.value.other_amount = checkout.other_amount
|
||||||
|
counterForm.value.deposit_deduct_amount = checkout.deposit_deduct_amount
|
||||||
|
counterForm.value.evidenceText = (checkout.evidence_urls || []).join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function linesToList(value: string) {
|
||||||
|
return value
|
||||||
|
.split('\n')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
function readError(error: unknown, fallback: string) {
|
function readError(error: unknown, fallback: string) {
|
||||||
if (typeof error === 'object' && error && 'response' in error) {
|
if (typeof error === 'object' && error && 'response' in error) {
|
||||||
const response = (error as { response?: { data?: { message?: string } } }).response
|
const response = (error as { response?: { data?: { message?: string } } }).response
|
||||||
@@ -240,21 +345,144 @@ function readError(error: unknown, fallback: string) {
|
|||||||
<el-button type="primary" :loading="confirming" @click="handleConfirmReceive">确认已收到账号</el-button>
|
<el-button type="primary" :loading="confirming" @click="handleConfirmReceive">确认已收到账号</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="order && isRenter && ['renting', 'overdue'].includes(order.status)" class="order-panel">
|
<div v-if="order?.checkout" class="order-panel">
|
||||||
<h2>提交归还</h2>
|
<h2>结账明细</h2>
|
||||||
<el-input v-model="returnContent" type="textarea" :rows="4" placeholder="填写归还说明、租后资产状态或注意事项" />
|
<div class="detail-grid">
|
||||||
<el-button class="panel-action" type="primary" :loading="returning" @click="handleSubmitReturn">提交归还</el-button>
|
<div class="metric-card">
|
||||||
|
<span>押金扣除</span>
|
||||||
|
<strong>¥{{ order.checkout.deposit_deduct_amount }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<span>租客退回</span>
|
||||||
|
<strong>¥{{ order.checkout.renter_refund_amount }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<span>号主入账</span>
|
||||||
|
<strong>¥{{ order.checkout.owner_income_amount }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<span>消耗</span>
|
||||||
|
<strong>{{ order.checkout.coin_consumed_m }}M</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="order.checkout.content">租客说明:{{ order.checkout.content }}</p>
|
||||||
|
<p v-if="order.checkout.owner_adjustment_reason">号主修正:{{ order.checkout.owner_adjustment_reason }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="order && isOwner && order.status === 'pending_return_confirm'" class="order-panel">
|
<div v-if="order && isRenter && ['renting', 'overdue'].includes(order.status)" class="order-panel">
|
||||||
<h2>确认归还</h2>
|
<h2>发起结账</h2>
|
||||||
<p>确认账号状态无误后,订单会完成,账号重新上架。</p>
|
<el-input v-model="checkoutForm.content" type="textarea" :rows="4" placeholder="填写使用结束说明、账号状态和需要号主核对的内容" />
|
||||||
<el-button type="primary" :loading="completing" @click="handleConfirmReturn">确认归还并完成订单</el-button>
|
<div class="form-grid panel-action">
|
||||||
|
<el-input-number
|
||||||
|
v-model="checkoutForm.consumable_amount"
|
||||||
|
class="full-control"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
controls-position="right"
|
||||||
|
placeholder="消耗扣款"
|
||||||
|
/>
|
||||||
|
<el-input-number
|
||||||
|
v-model="checkoutForm.coin_consumed_m"
|
||||||
|
class="full-control"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
controls-position="right"
|
||||||
|
placeholder="消耗哈夫币 M"
|
||||||
|
/>
|
||||||
|
<el-input-number
|
||||||
|
v-model="checkoutForm.other_amount"
|
||||||
|
class="full-control"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
controls-position="right"
|
||||||
|
placeholder="其他扣款"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
v-model="checkoutForm.evidenceText"
|
||||||
|
class="panel-action"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="结账证据链接,一行一个。可填写截图地址或备注链接"
|
||||||
|
/>
|
||||||
|
<el-button class="panel-action" type="primary" :loading="returning" @click="handleSubmitCheckout">发起结账</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="order && isOwner && order.status === 'pending_checkout_confirm'" class="order-panel">
|
||||||
|
<h2>确认结账</h2>
|
||||||
|
<p>确认账号状态和扣款金额无误后,订单会完成,账号重新上架。</p>
|
||||||
|
<el-button type="primary" :loading="completing" @click="handleConfirmCheckout">确认结账并完成订单</el-button>
|
||||||
|
|
||||||
|
<h2 class="panel-action">修改结账</h2>
|
||||||
|
<div class="form-grid">
|
||||||
|
<el-input-number
|
||||||
|
v-model="counterForm.consumable_amount"
|
||||||
|
class="full-control"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
controls-position="right"
|
||||||
|
placeholder="消耗扣款"
|
||||||
|
/>
|
||||||
|
<el-input-number
|
||||||
|
v-model="counterForm.coin_consumed_m"
|
||||||
|
class="full-control"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
controls-position="right"
|
||||||
|
placeholder="消耗哈夫币 M"
|
||||||
|
/>
|
||||||
|
<el-input-number
|
||||||
|
v-model="counterForm.other_amount"
|
||||||
|
class="full-control"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
controls-position="right"
|
||||||
|
placeholder="其他扣款"
|
||||||
|
/>
|
||||||
|
<el-input-number
|
||||||
|
v-model="counterForm.deposit_deduct_amount"
|
||||||
|
class="full-control"
|
||||||
|
:min="0"
|
||||||
|
:max="order.deposit_amount"
|
||||||
|
:precision="2"
|
||||||
|
controls-position="right"
|
||||||
|
placeholder="押金扣除"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
v-model="counterForm.reason"
|
||||||
|
class="panel-action"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="填写修改原因,例如额外资产损耗、哈夫币消耗差异或截图核对结果"
|
||||||
|
/>
|
||||||
|
<el-input
|
||||||
|
v-model="counterForm.evidenceText"
|
||||||
|
class="panel-action"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="修正证据链接,一行一个"
|
||||||
|
/>
|
||||||
|
<el-button class="panel-action" type="warning" :loading="countering" @click="handleCounterCheckout">提交修正给租客确认</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="order && isRenter && order.status === 'pending_checkout_accept'" class="order-panel">
|
||||||
|
<h2>确认修正结账</h2>
|
||||||
|
<p>同意后订单会完成结算;不同意会进入结账争议,由客服仲裁。</p>
|
||||||
|
<el-button type="primary" :loading="acceptingCheckout" @click="handleAcceptCheckout">同意修正并完成订单</el-button>
|
||||||
|
<el-input
|
||||||
|
v-model="rejectReason"
|
||||||
|
class="panel-action"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="不同意时填写原因,会进入争议处理"
|
||||||
|
/>
|
||||||
|
<el-button class="panel-action" type="danger" :loading="rejectingCheckout" @click="handleRejectCheckout">拒绝修正并发起争议</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="order && canOpenDispute" class="order-panel">
|
<div v-if="order && canOpenDispute" class="order-panel">
|
||||||
<h2>发起申诉</h2>
|
<h2>{{ isCheckoutDisputeStage ? '发起结账争议' : '发起申诉' }}</h2>
|
||||||
<el-select v-model="disputeType" class="full-control" placeholder="选择申诉类型">
|
<el-select v-if="!isCheckoutDisputeStage" v-model="disputeType" class="full-control" placeholder="选择申诉类型">
|
||||||
<el-option label="无法登录" value="cannot_login" />
|
<el-option label="无法登录" value="cannot_login" />
|
||||||
<el-option label="虚假描述" value="false_description" />
|
<el-option label="虚假描述" value="false_description" />
|
||||||
<el-option label="账号被封" value="account_banned" />
|
<el-option label="账号被封" value="account_banned" />
|
||||||
@@ -280,7 +508,9 @@ function readError(error: unknown, fallback: string) {
|
|||||||
<div class="panel-action upload-line">
|
<div class="panel-action upload-line">
|
||||||
<input type="file" accept="image/jpeg,image/png,image/webp,application/pdf" :disabled="uploadingEvidence" @change="handleEvidenceUpload" />
|
<input type="file" accept="image/jpeg,image/png,image/webp,application/pdf" :disabled="uploadingEvidence" @change="handleEvidenceUpload" />
|
||||||
</div>
|
</div>
|
||||||
<el-button class="panel-action" type="warning" :loading="disputing" @click="handleCreateDispute">提交申诉</el-button>
|
<el-button class="panel-action" type="warning" :loading="disputing" @click="handleCreateDispute">
|
||||||
|
{{ isCheckoutDisputeStage ? '提交结账争议' : '提交申诉' }}
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,22 +1,33 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
|
|
||||||
import { fetchOrders, type Order } from '@/api/orders'
|
import { fetchOrders, type OrderListItem } from '@/api/orders'
|
||||||
import { orderStatusLabel } from '@/utils/statusLabels'
|
import { orderStatusLabel } from '@/utils/statusLabels'
|
||||||
|
import { formatDateTime } from '@/utils/time'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const orders = ref<Order[]>([])
|
const orders = ref<OrderListItem[]>([])
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const currentPageSize = ref(20)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
onMounted(loadOrders)
|
onMounted(loadOrders)
|
||||||
|
|
||||||
async function loadOrders() {
|
async function loadOrders() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
orders.value = await fetchOrders()
|
const result = await fetchOrders(currentPage.value, currentPageSize.value)
|
||||||
|
orders.value = result.items
|
||||||
|
total.value = result.total
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSizeChange() {
|
||||||
|
currentPage.value = 1
|
||||||
|
loadOrders()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -24,17 +35,24 @@ async function loadOrders() {
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<p class="eyebrow">Orders</p>
|
<p class="eyebrow">Orders</p>
|
||||||
<h1>我的订单</h1>
|
<h1>我的订单</h1>
|
||||||
<p>跟踪待交接、使用中、待归还、申诉中和已完成订单。</p>
|
<p>跟踪待交接、使用中、待结账、申诉中和已完成订单。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" class="table-panel" :data="orders">
|
<el-table v-loading="loading" class="table-panel" :data="orders">
|
||||||
<el-table-column prop="order_no" label="订单号" min-width="210" />
|
<el-table-column prop="order_no" label="订单号" min-width="210" />
|
||||||
<el-table-column prop="title" label="账号" min-width="180" />
|
<el-table-column prop="listing_title" label="商品名" min-width="180" />
|
||||||
<el-table-column prop="rent_amount" label="订单金额" width="100" />
|
<el-table-column label="金额" min-width="130">
|
||||||
<el-table-column prop="deposit_amount" label="押金" width="100" />
|
<template #default="{ row }">
|
||||||
|
<span>¥{{ row.rent_amount }}</span>
|
||||||
|
<span class="table-subtext">押金 ¥{{ row.deposit_amount }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="140">
|
<el-table-column label="状态" width="140">
|
||||||
<template #default="{ row }">{{ orderStatusLabel(row.status) }}</template>
|
<template #default="{ row }">{{ orderStatusLabel(row.status) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="创建时间" min-width="180">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="100">
|
<el-table-column label="操作" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<RouterLink :to="`/orders/${row.id}`">
|
<RouterLink :to="`/orders/${row.id}`">
|
||||||
@@ -43,5 +61,17 @@ async function loadOrders() {
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pagination-wrap" v-if="total > 0">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="currentPageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@current-change="loadOrders"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -8,19 +8,32 @@ import { formatDateTime } from '@/utils/time'
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const account = ref<WalletAccount | null>(null)
|
const account = ref<WalletAccount | null>(null)
|
||||||
const ledger = ref<WalletLedger[]>([])
|
const ledger = ref<WalletLedger[]>([])
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const currentPageSize = ref(20)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
onMounted(loadWallet)
|
onMounted(loadWallet)
|
||||||
|
|
||||||
async function loadWallet() {
|
async function loadWallet() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const [balance, rows] = await Promise.all([fetchWalletBalance(), fetchWalletLedger()])
|
const [balance, result] = await Promise.all([fetchWalletBalance(), fetchWalletLedger(currentPage.value, currentPageSize.value)])
|
||||||
account.value = balance
|
account.value = balance
|
||||||
ledger.value = rows
|
ledger.value = result.items
|
||||||
|
total.value = result.total
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSizeChange() {
|
||||||
|
currentPage.value = 1
|
||||||
|
loadWallet()
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLedgerPage() {
|
||||||
|
loadWallet()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -28,7 +41,7 @@ async function loadWallet() {
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<p class="eyebrow">Wallet</p>
|
<p class="eyebrow">Wallet</p>
|
||||||
<h1>钱包</h1>
|
<h1>钱包</h1>
|
||||||
<p>查看可用余额、冻结余额和不可变资金流水。当前是开发态模拟账务,不代表真实支付余额。</p>
|
<p>查看可用余额、冻结余额和资金流水。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="account" class="metric-grid">
|
<div v-if="account" class="metric-grid">
|
||||||
@@ -62,5 +75,17 @@ async function loadWallet() {
|
|||||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pagination-wrap" v-if="total > 0">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="currentPageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@current-change="loadLedgerPage"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -8,22 +8,30 @@ import { formatDateTime } from '@/utils/time'
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const logs = ref<AdminAuditLog[]>([])
|
const logs = ref<AdminAuditLog[]>([])
|
||||||
const activeLog = ref<AdminAuditLog | null>(null)
|
const activeLog = ref<AdminAuditLog | null>(null)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const currentPageSize = ref(20)
|
||||||
|
const total = ref(0)
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
actor_id: '',
|
actor_id: '',
|
||||||
action: '',
|
action: '',
|
||||||
biz_type: '',
|
biz_type: '',
|
||||||
limit: 200,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const highRiskCount = computed(() => logs.value.filter((item) => item.action.includes('freeze') || item.action.includes('update')).length)
|
const highRiskCount = computed(() => logs.value.filter((item) => item.action.includes('freeze') || item.action.includes('update')).length)
|
||||||
const uniqueActors = computed(() => new Set(logs.value.map((item) => item.actor_id)).size)
|
|
||||||
|
|
||||||
onMounted(loadLogs)
|
onMounted(loadLogs)
|
||||||
|
|
||||||
async function loadLogs() {
|
async function loadLogs() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
logs.value = await fetchAdminAuditLogs(filters)
|
const query = {
|
||||||
|
...filters,
|
||||||
|
page: currentPage.value,
|
||||||
|
page_size: currentPageSize.value,
|
||||||
|
}
|
||||||
|
const result = await fetchAdminAuditLogs(query)
|
||||||
|
logs.value = result.items
|
||||||
|
total.value = result.total
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -33,10 +41,15 @@ function resetFilters() {
|
|||||||
filters.actor_id = ''
|
filters.actor_id = ''
|
||||||
filters.action = ''
|
filters.action = ''
|
||||||
filters.biz_type = ''
|
filters.biz_type = ''
|
||||||
filters.limit = 200
|
currentPage.value = 1
|
||||||
void loadLogs()
|
void loadLogs()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSizeChange() {
|
||||||
|
currentPage.value = 1
|
||||||
|
loadLogs()
|
||||||
|
}
|
||||||
|
|
||||||
function actorName(row: AdminAuditLog) {
|
function actorName(row: AdminAuditLog) {
|
||||||
return row.actor_nickname || row.actor_username || `${row.actor_type} ${row.actor_id}`
|
return row.actor_nickname || row.actor_username || `${row.actor_type} ${row.actor_id}`
|
||||||
}
|
}
|
||||||
@@ -59,7 +72,7 @@ function actionType(action: string) {
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<p class="eyebrow">Audit Logs</p>
|
<p class="eyebrow">Audit Logs</p>
|
||||||
<h1>审计日志</h1>
|
<h1>审计日志</h1>
|
||||||
<p>查看后台管理员操作、业务对象、来源 IP 和操作明细,用于追踪冻结、配置修改等高风险动作。</p>
|
<p>查看后台管理员操作、业务对象和操作明细,用于追踪冻结、配置修改等高风险动作。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
<el-button @click="resetFilters">重置</el-button>
|
<el-button @click="resetFilters">重置</el-button>
|
||||||
@@ -69,12 +82,8 @@ function actionType(action: string) {
|
|||||||
|
|
||||||
<div class="metric-grid">
|
<div class="metric-grid">
|
||||||
<div class="metric-card">
|
<div class="metric-card">
|
||||||
<span>当前结果</span>
|
<span>总条数</span>
|
||||||
<strong>{{ logs.length }} 条</strong>
|
<strong>{{ total }} 条</strong>
|
||||||
</div>
|
|
||||||
<div class="metric-card">
|
|
||||||
<span>操作管理员</span>
|
|
||||||
<strong>{{ uniqueActors }} 人</strong>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-card">
|
<div class="metric-card">
|
||||||
<span>高风险动作</span>
|
<span>高风险动作</span>
|
||||||
@@ -94,13 +103,8 @@ function actionType(action: string) {
|
|||||||
<el-option label="商品标记异常" value="listing.mark_abnormal" />
|
<el-option label="商品标记异常" value="listing.mark_abnormal" />
|
||||||
<el-option label="客服关闭订单" value="order.admin_close" />
|
<el-option label="客服关闭订单" value="order.admin_close" />
|
||||||
<el-option label="订单标记异常" value="order.mark_abnormal" />
|
<el-option label="订单标记异常" value="order.mark_abnormal" />
|
||||||
<el-option label="号主交接超时" value="order.timeout.owner_submit" />
|
|
||||||
<el-option label="租客确认超时" value="order.timeout.renter_confirm" />
|
|
||||||
<el-option label="租客归还逾期" value="order.timeout.return_overdue" />
|
|
||||||
<el-option label="号主确认归还超时" value="order.timeout.owner_return_confirm" />
|
|
||||||
<el-option label="申诉仲裁" value="dispute.arbitrate" />
|
<el-option label="申诉仲裁" value="dispute.arbitrate" />
|
||||||
<el-option label="更新系统配置" value="system_config.update" />
|
<el-option label="更新系统配置" value="system_config.update" />
|
||||||
<el-option label="创建系统配置" value="system_config.create" />
|
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="业务类型">
|
<el-form-item label="业务类型">
|
||||||
@@ -112,17 +116,12 @@ function actionType(action: string) {
|
|||||||
<el-option label="系统配置" value="system_config" />
|
<el-option label="系统配置" value="system_config" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</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-form>
|
||||||
|
|
||||||
<el-table v-loading="loading" class="table-panel" :data="logs">
|
<el-table v-loading="loading" class="table-panel" :data="logs">
|
||||||
<el-table-column prop="id" label="ID" width="90" />
|
<el-table-column label="操作人" min-width="160">
|
||||||
<el-table-column label="管理员" min-width="160">
|
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<strong>{{ actorName(row) }}</strong>
|
<strong>{{ actorName(row) }}</strong>
|
||||||
<span class="table-subtext">{{ row.actor_type }} ID: {{ row.actor_id }}</span>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="动作" min-width="180">
|
<el-table-column label="动作" min-width="180">
|
||||||
@@ -132,8 +131,6 @@ function actionType(action: string) {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="biz_type" label="业务类型" width="130" />
|
<el-table-column prop="biz_type" label="业务类型" width="130" />
|
||||||
<el-table-column prop="biz_id" label="业务 ID" width="100" />
|
<el-table-column prop="biz_id" label="业务 ID" width="100" />
|
||||||
<el-table-column prop="ip" label="IP" min-width="130" />
|
|
||||||
<el-table-column prop="user_agent" label="User-Agent" min-width="240" show-overflow-tooltip />
|
|
||||||
<el-table-column label="时间" min-width="180">
|
<el-table-column label="时间" min-width="180">
|
||||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -144,9 +141,23 @@ function actionType(action: string) {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pagination-wrap" v-if="total > 0">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="currentPageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@current-change="loadLogs"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-dialog :model-value="!!activeLog" title="审计明细" width="720px" @update:model-value="activeLog = null">
|
<el-dialog :model-value="!!activeLog" title="审计明细" width="720px" @update:model-value="activeLog = null">
|
||||||
<div v-if="activeLog" class="dialog-body">
|
<div v-if="activeLog" class="dialog-body">
|
||||||
<p><strong>{{ activeLog.action }}</strong> · {{ activeLog.biz_type }} #{{ activeLog.biz_id || '-' }}</p>
|
<p><strong>{{ activeLog.action }}</strong> · {{ activeLog.biz_type }} #{{ activeLog.biz_id || '-' }}</p>
|
||||||
|
<p>操作人:{{ actorName(activeLog) }} · IP:{{ activeLog.ip }}</p>
|
||||||
|
<p>User-Agent:{{ activeLog.user_agent }}</p>
|
||||||
<div class="code-panel">
|
<div class="code-panel">
|
||||||
<pre>{{ detailText(activeLog) }}</pre>
|
<pre>{{ detailText(activeLog) }}</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ function money(value?: number) {
|
|||||||
<strong>{{ dashboard.pending.pending_handoffs }}</strong>
|
<strong>{{ dashboard.pending.pending_handoffs }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="pending-row">
|
<div class="pending-row">
|
||||||
<span>待归还确认</span>
|
<span>待结账确认</span>
|
||||||
<strong>{{ dashboard.pending.pending_return_confirms }}</strong>
|
<strong>{{ dashboard.pending.pending_return_confirms }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { onMounted, ref } from 'vue'
|
|||||||
import { arbitrateDispute, fetchAdminDisputes, type Dispute } from '@/api/disputes'
|
import { arbitrateDispute, fetchAdminDisputes, type Dispute } from '@/api/disputes'
|
||||||
import { fetchAdminFileBlob } from '@/api/files'
|
import { fetchAdminFileBlob } from '@/api/files'
|
||||||
import { disputeStatusLabel } from '@/utils/statusLabels'
|
import { disputeStatusLabel } from '@/utils/statusLabels'
|
||||||
|
import { formatDateTime } from '@/utils/time'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
@@ -14,18 +15,28 @@ const evidenceDispute = ref<Dispute | null>(null)
|
|||||||
const result = ref('release_deposit')
|
const result = ref('release_deposit')
|
||||||
const remark = ref('')
|
const remark = ref('')
|
||||||
const amount = ref<number | undefined>()
|
const amount = ref<number | undefined>()
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const currentPageSize = ref(20)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
onMounted(loadDisputes)
|
onMounted(loadDisputes)
|
||||||
|
|
||||||
async function loadDisputes() {
|
async function loadDisputes() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
disputes.value = await fetchAdminDisputes()
|
const res = await fetchAdminDisputes(currentPage.value, currentPageSize.value)
|
||||||
|
disputes.value = res.items
|
||||||
|
total.value = res.total
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSizeChange() {
|
||||||
|
currentPage.value = 1
|
||||||
|
loadDisputes()
|
||||||
|
}
|
||||||
|
|
||||||
function openArbitration(row: Dispute) {
|
function openArbitration(row: Dispute) {
|
||||||
activeDispute.value = row
|
activeDispute.value = row
|
||||||
result.value = row.arbitration_result || 'release_deposit'
|
result.value = row.arbitration_result || 'release_deposit'
|
||||||
@@ -99,7 +110,7 @@ function readError(error: unknown, fallback: string) {
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<p class="eyebrow">Arbitration</p>
|
<p class="eyebrow">Arbitration</p>
|
||||||
<h1>仲裁中心</h1>
|
<h1>仲裁中心</h1>
|
||||||
<p>处理无法登录、资产损失、哈夫币争议和超时归还。</p>
|
<p>处理无法登录、资产损失、哈夫币争议和结账争议。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" class="table-panel" :data="disputes">
|
<el-table v-loading="loading" class="table-panel" :data="disputes">
|
||||||
@@ -109,20 +120,30 @@ function readError(error: unknown, fallback: string) {
|
|||||||
<el-table-column label="状态" width="110">
|
<el-table-column label="状态" width="110">
|
||||||
<template #default="{ row }">{{ disputeStatusLabel(row.status) }}</template>
|
<template #default="{ row }">{{ disputeStatusLabel(row.status) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="description" label="说明" min-width="220" show-overflow-tooltip />
|
<el-table-column label="创建时间" min-width="180">
|
||||||
<el-table-column prop="arbitration_result" label="结果" width="150" />
|
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||||
<el-table-column label="证据" width="100">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-button size="small" :disabled="evidenceItems(row).length === 0" @click="evidenceDispute = row">查看</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="120">
|
<el-table-column prop="arbitration_result" label="仲裁结果" width="150" show-overflow-tooltip />
|
||||||
|
<el-table-column label="操作" width="160">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" :disabled="evidenceItems(row).length === 0" @click="evidenceDispute = row">证据</el-button>
|
||||||
<el-button size="small" :disabled="row.status === 'resolved'" @click="openArbitration(row)">仲裁</el-button>
|
<el-button size="small" :disabled="row.status === 'resolved'" @click="openArbitration(row)">仲裁</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pagination-wrap" v-if="total > 0">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="currentPageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@current-change="loadDisputes"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-dialog :model-value="!!activeDispute" title="申诉仲裁" width="560px" @update:model-value="activeDispute = null">
|
<el-dialog :model-value="!!activeDispute" title="申诉仲裁" width="560px" @update:model-value="activeDispute = null">
|
||||||
<div v-if="activeDispute" class="dialog-body">
|
<div v-if="activeDispute" class="dialog-body">
|
||||||
<p><strong>{{ activeDispute.order_no }}</strong> · {{ activeDispute.title }}</p>
|
<p><strong>{{ activeDispute.order_no }}</strong> · {{ activeDispute.title }}</p>
|
||||||
@@ -134,6 +155,7 @@ function readError(error: unknown, fallback: string) {
|
|||||||
<el-option label="释放押金" value="release_deposit" />
|
<el-option label="释放押金" value="release_deposit" />
|
||||||
<el-option label="赔付号主" value="compensate_owner" />
|
<el-option label="赔付号主" value="compensate_owner" />
|
||||||
<el-option label="关闭订单" value="order_close" />
|
<el-option label="关闭订单" value="order_close" />
|
||||||
|
<el-option label="标记异常" value="mark_abnormal" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-if="['partial_refund', 'deduct_deposit', 'compensate_owner'].includes(result)"
|
v-if="['partial_refund', 'deduct_deposit', 'compensate_owner'].includes(result)"
|
||||||
|
|||||||
@@ -5,6 +5,19 @@ import { useRoute } from 'vue-router'
|
|||||||
|
|
||||||
import { fetchAdminFileBlob } from '@/api/files'
|
import { fetchAdminFileBlob } from '@/api/files'
|
||||||
import { adminMarkListingAbnormal, adminOfflineListing, fetchAdminListing, type Listing } from '@/api/listings'
|
import { adminMarkListingAbnormal, adminOfflineListing, fetchAdminListing, type Listing } from '@/api/listings'
|
||||||
|
import {
|
||||||
|
formatMoney,
|
||||||
|
formatRatio,
|
||||||
|
getAdminListingPricing,
|
||||||
|
listingAssetTags,
|
||||||
|
listingBadgeTags,
|
||||||
|
listingBuyerPrice,
|
||||||
|
listingDisplayTitle,
|
||||||
|
listingResourceTags,
|
||||||
|
listingRiskTags,
|
||||||
|
listingSellerPrice,
|
||||||
|
ownerName,
|
||||||
|
} from '@/views/admin/listingAdminDisplay'
|
||||||
import { listingReviewStatusLabel, listingStatusLabel } from '@/utils/statusLabels'
|
import { listingReviewStatusLabel, listingStatusLabel } from '@/utils/statusLabels'
|
||||||
import { formatDateTime } from '@/utils/time'
|
import { formatDateTime } from '@/utils/time'
|
||||||
|
|
||||||
@@ -53,14 +66,6 @@ async function submitAction() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function money(value: number) {
|
|
||||||
return `¥${Number(value || 0).toFixed(2)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function listingPrice(row: Listing) {
|
|
||||||
return money(row.price_daily || row.price_hourly)
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractObjectKey(url: string) {
|
function extractObjectKey(url: string) {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url, window.location.origin)
|
const parsed = new URL(url, window.location.origin)
|
||||||
@@ -94,7 +99,7 @@ function readError(error: unknown, fallback: string) {
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<p class="eyebrow">Listing #{{ listing.id }}</p>
|
<p class="eyebrow">Listing #{{ listing.id }}</p>
|
||||||
<h1>商品详情</h1>
|
<h1>商品详情</h1>
|
||||||
<p>{{ listing.title }} · {{ listing.server_region }} / {{ listing.login_platform }}</p>
|
<p>{{ listingDisplayTitle(listing) }} · {{ listing.server_region }} / {{ listing.login_platform }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
<RouterLink to="/admin/listings">
|
<RouterLink to="/admin/listings">
|
||||||
@@ -115,18 +120,20 @@ function readError(error: unknown, fallback: string) {
|
|||||||
<strong>{{ listingReviewStatusLabel(listing.review_status) }}</strong>
|
<strong>{{ listingReviewStatusLabel(listing.review_status) }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-card">
|
<div class="metric-card">
|
||||||
<span>价格</span>
|
<span>买家日租</span>
|
||||||
<strong>{{ listingPrice(listing) }}</strong>
|
<strong>{{ listingBuyerPrice(listing) }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-card">
|
<div class="metric-card">
|
||||||
<span>押金</span>
|
<span>押金</span>
|
||||||
<strong>{{ money(listing.deposit_amount) }}</strong>
|
<strong>{{ formatMoney(listing.deposit_amount) }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="listing" class="dashboard-panels">
|
<div v-if="listing" class="dashboard-panels">
|
||||||
<div class="order-panel dashboard-panel">
|
<div class="order-panel dashboard-panel">
|
||||||
<h2>账号信息</h2>
|
<h2>账号信息</h2>
|
||||||
|
<p>展示标题:{{ listingDisplayTitle(listing) }}</p>
|
||||||
|
<p>原始标题:{{ listing.title }}</p>
|
||||||
<p>账号 ID:{{ listing.account_id }}</p>
|
<p>账号 ID:{{ listing.account_id }}</p>
|
||||||
<p>游戏:{{ listing.game_name }}</p>
|
<p>游戏:{{ listing.game_name }}</p>
|
||||||
<p>区服:{{ listing.server_region }}</p>
|
<p>区服:{{ listing.server_region }}</p>
|
||||||
@@ -134,17 +141,58 @@ function readError(error: unknown, fallback: string) {
|
|||||||
<p>段位:{{ listing.rank_level || '-' }}</p>
|
<p>段位:{{ listing.rank_level || '-' }}</p>
|
||||||
<p>哈夫币:{{ listing.haf_coin_amount }}</p>
|
<p>哈夫币:{{ listing.haf_coin_amount }}</p>
|
||||||
<p>资产截图:{{ listing.screenshot_urls?.length || 0 }} 个</p>
|
<p>资产截图:{{ listing.screenshot_urls?.length || 0 }} 个</p>
|
||||||
|
<div v-if="listingBadgeTags(listing).length" class="admin-tag-row">
|
||||||
|
<el-tag
|
||||||
|
v-for="tag in listingBadgeTags(listing)"
|
||||||
|
:key="tag"
|
||||||
|
size="small"
|
||||||
|
:type="tag === '特惠' ? 'danger' : 'warning'"
|
||||||
|
effect="plain"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="order-panel dashboard-panel">
|
<div class="order-panel dashboard-panel">
|
||||||
<h2>号主与价格</h2>
|
<h2>号主与价格</h2>
|
||||||
<p>号主:{{ listing.owner_phone || listing.owner_nickname || listing.owner_id }}</p>
|
<p>号主:{{ ownerName(listing) }}</p>
|
||||||
<p>号主 ID:{{ listing.owner_id }}</p>
|
<p>号主 ID:{{ listing.owner_id }}</p>
|
||||||
<p>价格:{{ listingPrice(listing) }}</p>
|
<p>买家日租:{{ listingBuyerPrice(listing) }}</p>
|
||||||
<p>押金:{{ money(listing.deposit_amount) }}</p>
|
<p>卖家价格:{{ listingSellerPrice(listing) }}</p>
|
||||||
|
<p>押金:{{ formatMoney(listing.deposit_amount) }}</p>
|
||||||
|
<p>卖家比例:{{ formatRatio(getAdminListingPricing(listing).sellerRatio) }}</p>
|
||||||
|
<p>买家比例:{{ formatRatio(getAdminListingPricing(listing).buyerRatio) }}</p>
|
||||||
|
<p>平台加价:{{ formatMoney(getAdminListingPricing(listing).platformMarkup) }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="listing" class="dashboard-panels">
|
||||||
|
<div class="order-panel dashboard-panel">
|
||||||
|
<h2>资产配置</h2>
|
||||||
|
<div class="admin-tag-row">
|
||||||
|
<el-tag v-for="tag in listingAssetTags(listing)" :key="tag" effect="plain">{{ tag }}</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="order-panel dashboard-panel">
|
||||||
|
<h2>交接风控</h2>
|
||||||
|
<div class="admin-tag-row">
|
||||||
|
<el-tag v-for="tag in listingRiskTags(listing)" :key="tag" type="info" effect="plain">{{ tag }}</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="listing" class="order-panel dashboard-panel">
|
||||||
|
<h2>额外资源</h2>
|
||||||
|
<div v-if="listingResourceTags(listing, 99).length" class="admin-tag-row">
|
||||||
|
<el-tag v-for="tag in listingResourceTags(listing, 99)" :key="tag" type="info" effect="plain">
|
||||||
|
{{ tag }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<p v-else>暂无额外资源</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="listing" class="order-panel dashboard-panel">
|
<div v-if="listing" class="order-panel dashboard-panel">
|
||||||
<h2>说明与审核原因</h2>
|
<h2>说明与审核原因</h2>
|
||||||
<p>{{ listing.description || '暂无商品说明' }}</p>
|
<p>{{ listing.description || '暂无商品说明' }}</p>
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import { onMounted, ref } from 'vue'
|
|||||||
|
|
||||||
import { fetchAdminFileBlob } from '@/api/files'
|
import { fetchAdminFileBlob } from '@/api/files'
|
||||||
import { approveListing, fetchPendingReviewListings, rejectListing, type Listing } from '@/api/listings'
|
import { approveListing, fetchPendingReviewListings, rejectListing, type Listing } from '@/api/listings'
|
||||||
|
import {
|
||||||
|
formatMoney,
|
||||||
|
listingBuyerPrice,
|
||||||
|
listingDisplayTitle,
|
||||||
|
listingSellerPrice,
|
||||||
|
ownerName,
|
||||||
|
} from '@/views/admin/listingAdminDisplay'
|
||||||
import { formatDateTime } from '@/utils/time'
|
import { formatDateTime } from '@/utils/time'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -12,18 +19,28 @@ const listings = ref<Listing[]>([])
|
|||||||
const activeListing = ref<Listing | null>(null)
|
const activeListing = ref<Listing | null>(null)
|
||||||
const evidenceListing = ref<Listing | null>(null)
|
const evidenceListing = ref<Listing | null>(null)
|
||||||
const rejectReason = ref('')
|
const rejectReason = ref('')
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const currentPageSize = ref(20)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
onMounted(loadListings)
|
onMounted(loadListings)
|
||||||
|
|
||||||
async function loadListings() {
|
async function loadListings() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
listings.value = await fetchPendingReviewListings()
|
const result = await fetchPendingReviewListings(currentPage.value, currentPageSize.value)
|
||||||
|
listings.value = result.items
|
||||||
|
total.value = result.total
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSizeChange() {
|
||||||
|
currentPage.value = 1
|
||||||
|
loadListings()
|
||||||
|
}
|
||||||
|
|
||||||
async function handleApprove(row: Listing) {
|
async function handleApprove(row: Listing) {
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
@@ -62,10 +79,6 @@ function openEvidence(row: Listing) {
|
|||||||
evidenceListing.value = row
|
evidenceListing.value = row
|
||||||
}
|
}
|
||||||
|
|
||||||
function listingPrice(row: Listing) {
|
|
||||||
return `¥${Number(row.price_daily || row.price_hourly || 0).toFixed(2)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractObjectKey(url: string) {
|
function extractObjectKey(url: string) {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url, window.location.origin)
|
const parsed = new URL(url, window.location.origin)
|
||||||
@@ -105,16 +118,23 @@ function readError(error: unknown, fallback: string) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" class="table-panel" :data="listings">
|
<el-table v-loading="loading" class="table-panel" :data="listings">
|
||||||
<el-table-column prop="title" label="标题" min-width="180" />
|
<el-table-column label="商品" min-width="220" fixed="left">
|
||||||
<el-table-column prop="owner_id" label="号主" width="90" />
|
<template #default="{ row }">
|
||||||
<el-table-column prop="server_region" label="区服" width="120" />
|
<strong>{{ listingDisplayTitle(row) }}</strong>
|
||||||
<el-table-column prop="login_platform" label="平台" width="120" />
|
<span class="table-subtext">{{ row.server_region }} / {{ row.login_platform }}</span>
|
||||||
<el-table-column prop="haf_coin_amount" label="哈夫币" width="110" />
|
</template>
|
||||||
<el-table-column prop="rank_level" label="段位" width="110" />
|
</el-table-column>
|
||||||
<el-table-column label="价格" width="90">
|
<el-table-column label="号主" min-width="130">
|
||||||
<template #default="{ row }">{{ listingPrice(row) }}</template>
|
<template #default="{ row }">
|
||||||
|
<strong>{{ ownerName(row) }}</strong>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="价格" min-width="170">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<strong>{{ listingBuyerPrice(row) }}</strong>
|
||||||
|
<span class="table-subtext">卖家 {{ listingSellerPrice(row) }} · 押金 {{ formatMoney(row.deposit_amount) }}</span>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="deposit_amount" label="押金" width="90" />
|
|
||||||
<el-table-column label="截图" width="90">
|
<el-table-column label="截图" width="90">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button size="small" :disabled="!row.screenshot_urls?.length" @click="openEvidence(row)">
|
<el-button size="small" :disabled="!row.screenshot_urls?.length" @click="openEvidence(row)">
|
||||||
@@ -133,9 +153,23 @@ function readError(error: unknown, fallback: string) {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pagination-wrap" v-if="total > 0">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="currentPageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@current-change="loadListings"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-dialog :model-value="!!activeListing" title="拒绝发布" width="560px" @update:model-value="activeListing = null">
|
<el-dialog :model-value="!!activeListing" title="拒绝发布" width="560px" @update:model-value="activeListing = null">
|
||||||
<div v-if="activeListing" class="dialog-body">
|
<div v-if="activeListing" class="dialog-body">
|
||||||
<p><strong>{{ activeListing.title }}</strong></p>
|
<p><strong>{{ listingDisplayTitle(activeListing) }}</strong></p>
|
||||||
|
<p>买家日租:{{ listingBuyerPrice(activeListing) }},卖家价格:{{ listingSellerPrice(activeListing) }}</p>
|
||||||
|
<p>押金:{{ formatMoney(activeListing.deposit_amount) }}</p>
|
||||||
<el-input v-model="rejectReason" type="textarea" :rows="4" placeholder="填写拒绝原因,号主会在通知中看到审核结果" />
|
<el-input v-model="rejectReason" type="textarea" :rows="4" placeholder="填写拒绝原因,号主会在通知中看到审核结果" />
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -146,7 +180,7 @@ function readError(error: unknown, fallback: string) {
|
|||||||
|
|
||||||
<el-dialog :model-value="!!evidenceListing" title="账号资产截图" width="680px" @update:model-value="evidenceListing = null">
|
<el-dialog :model-value="!!evidenceListing" title="账号资产截图" width="680px" @update:model-value="evidenceListing = null">
|
||||||
<div v-if="evidenceListing" class="dialog-body">
|
<div v-if="evidenceListing" class="dialog-body">
|
||||||
<p><strong>{{ evidenceListing.title }}</strong></p>
|
<p><strong>{{ listingDisplayTitle(evidenceListing) }}</strong></p>
|
||||||
<div v-if="evidenceListing.screenshot_urls?.length" class="evidence-list">
|
<div v-if="evidenceListing.screenshot_urls?.length" class="evidence-list">
|
||||||
<div v-for="url in evidenceListing.screenshot_urls" :key="url" class="evidence-row">
|
<div v-for="url in evidenceListing.screenshot_urls" :key="url" class="evidence-row">
|
||||||
<span>{{ url }}</span>
|
<span>{{ url }}</span>
|
||||||
|
|||||||
@@ -3,16 +3,25 @@ import { Search } from '@element-plus/icons-vue'
|
|||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
|
||||||
import { fetchAdminListings, type Listing } from '@/api/listings'
|
import { fetchAdminListings, type Listing } from '@/api/listings'
|
||||||
|
import {
|
||||||
|
formatMoney,
|
||||||
|
listingBuyerPrice,
|
||||||
|
listingDisplayTitle,
|
||||||
|
listingSellerPrice,
|
||||||
|
ownerName,
|
||||||
|
} from '@/views/admin/listingAdminDisplay'
|
||||||
import { listingReviewStatusLabel, listingStatusLabel } from '@/utils/statusLabels'
|
import { listingReviewStatusLabel, listingStatusLabel } from '@/utils/statusLabels'
|
||||||
import { formatDateTime } from '@/utils/time'
|
import { formatDateTime } from '@/utils/time'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const listings = ref<Listing[]>([])
|
const listings = ref<Listing[]>([])
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const currentPageSize = ref(20)
|
||||||
|
const total = ref(0)
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
owner_id: '',
|
owner_id: '',
|
||||||
status: '',
|
status: '',
|
||||||
review_status: '',
|
review_status: '',
|
||||||
limit: 200,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const publishedCount = computed(() => listings.value.filter((item) => item.status === 'published').length)
|
const publishedCount = computed(() => listings.value.filter((item) => item.status === 'published').length)
|
||||||
@@ -24,7 +33,14 @@ onMounted(loadListings)
|
|||||||
async function loadListings() {
|
async function loadListings() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
listings.value = await fetchAdminListings(filters)
|
const query = {
|
||||||
|
...filters,
|
||||||
|
page: currentPage.value,
|
||||||
|
page_size: currentPageSize.value,
|
||||||
|
}
|
||||||
|
const result = await fetchAdminListings(query)
|
||||||
|
listings.value = result.items
|
||||||
|
total.value = result.total
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -34,20 +50,13 @@ function resetFilters() {
|
|||||||
filters.owner_id = ''
|
filters.owner_id = ''
|
||||||
filters.status = ''
|
filters.status = ''
|
||||||
filters.review_status = ''
|
filters.review_status = ''
|
||||||
filters.limit = 200
|
currentPage.value = 1
|
||||||
void loadListings()
|
void loadListings()
|
||||||
}
|
}
|
||||||
|
|
||||||
function money(value: number) {
|
function handleSizeChange() {
|
||||||
return `¥${Number(value || 0).toFixed(2)}`
|
currentPage.value = 1
|
||||||
}
|
loadListings()
|
||||||
|
|
||||||
function listingPrice(row: Listing) {
|
|
||||||
return money(row.price_daily || row.price_hourly)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ownerName(row: Listing) {
|
|
||||||
return row.owner_phone || row.owner_nickname || `号主 ${row.owner_id}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusType(status: string) {
|
function statusType(status: string) {
|
||||||
@@ -71,7 +80,7 @@ function reviewType(status: string) {
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<p class="eyebrow">Listings</p>
|
<p class="eyebrow">Listings</p>
|
||||||
<h1>商品管理</h1>
|
<h1>商品管理</h1>
|
||||||
<p>查看全部租号商品、号主、区服平台、价格押金、上架状态和审核状态。</p>
|
<p>查看全部租号商品、号主、价格押金、上架状态和审核状态。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
<el-button @click="resetFilters">重置</el-button>
|
<el-button @click="resetFilters">重置</el-button>
|
||||||
@@ -81,8 +90,8 @@ function reviewType(status: string) {
|
|||||||
|
|
||||||
<div class="metric-grid">
|
<div class="metric-grid">
|
||||||
<div class="metric-card">
|
<div class="metric-card">
|
||||||
<span>当前结果</span>
|
<span>总条数</span>
|
||||||
<strong>{{ listings.length }} 个</strong>
|
<strong>{{ total }} 个</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-card">
|
<div class="metric-card">
|
||||||
<span>已上架</span>
|
<span>已上架</span>
|
||||||
@@ -119,36 +128,21 @@ function reviewType(status: string) {
|
|||||||
<el-option label="已拒绝" value="rejected" />
|
<el-option label="已拒绝" value="rejected" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</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-form>
|
||||||
|
|
||||||
<el-table v-loading="loading" class="table-panel" :data="listings">
|
<el-table v-loading="loading" class="table-panel" :data="listings">
|
||||||
<el-table-column prop="id" label="ID" width="80" />
|
<el-table-column label="商品" min-width="220" fixed="left">
|
||||||
<el-table-column label="商品" min-width="220">
|
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<strong>{{ row.title }}</strong>
|
<strong>{{ listingDisplayTitle(row) }}</strong>
|
||||||
<span class="table-subtext">账号 ID: {{ row.account_id }}</span>
|
<span class="table-subtext">{{ row.game_name }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="号主" min-width="150">
|
<el-table-column label="号主" min-width="130">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<strong>{{ ownerName(row) }}</strong>
|
<strong>{{ ownerName(row) }}</strong>
|
||||||
<span class="table-subtext">ID: {{ row.owner_id }}</span>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="server_region" label="区服" width="120" />
|
<el-table-column label="状态" width="110">
|
||||||
<el-table-column prop="login_platform" label="平台" width="120" />
|
|
||||||
<el-table-column prop="rank_level" label="段位" width="110" />
|
|
||||||
<el-table-column prop="haf_coin_amount" label="哈夫币" width="110" />
|
|
||||||
<el-table-column label="价格" width="100">
|
|
||||||
<template #default="{ row }">{{ listingPrice(row) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="押金" width="100">
|
|
||||||
<template #default="{ row }">{{ money(row.deposit_amount) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="商品状态" width="110">
|
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="statusType(row.status)">{{ listingStatusLabel(row.status) }}</el-tag>
|
<el-tag :type="statusType(row.status)">{{ listingStatusLabel(row.status) }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
@@ -158,11 +152,14 @@ function reviewType(status: string) {
|
|||||||
<el-tag :type="reviewType(row.review_status)">{{ listingReviewStatusLabel(row.review_status) }}</el-tag>
|
<el-tag :type="reviewType(row.review_status)">{{ listingReviewStatusLabel(row.review_status) }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="上架时间" min-width="180">
|
<el-table-column label="价格(租金/押金)" min-width="170">
|
||||||
<template #default="{ row }">{{ formatDateTime(row.published_at) }}</template>
|
<template #default="{ row }">
|
||||||
|
<strong>{{ listingBuyerPrice(row) }}</strong>
|
||||||
|
<span class="table-subtext">卖家 {{ listingSellerPrice(row) }} · 押金 {{ formatMoney(row.deposit_amount) }}</span>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="更新时间" min-width="180">
|
<el-table-column label="创建时间" min-width="180">
|
||||||
<template #default="{ row }">{{ formatDateTime(row.updated_at) }}</template>
|
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="110">
|
<el-table-column label="操作" width="110">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -172,5 +169,17 @@ function reviewType(status: string) {
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pagination-wrap" v-if="total > 0">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="currentPageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@current-change="loadListings"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -121,6 +121,20 @@ function readError(error: unknown, fallback: string) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="order?.checkout" class="order-panel dashboard-panel">
|
||||||
|
<h2>结账信息</h2>
|
||||||
|
<div class="detail-grid checkout-grid">
|
||||||
|
<p>结账状态:{{ order.checkout.status }}</p>
|
||||||
|
<p>租金收入:¥{{ order.checkout.rent_amount }}</p>
|
||||||
|
<p>押金:¥{{ order.checkout.deposit_amount }}</p>
|
||||||
|
<p>消耗扣款:¥{{ order.checkout.deposit_deduct_amount }}</p>
|
||||||
|
<p>租客退回:¥{{ order.checkout.renter_refund_amount }}</p>
|
||||||
|
<p>号主入账:¥{{ order.checkout.owner_income_amount }}</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="order.checkout.content">租客说明:{{ order.checkout.content }}</p>
|
||||||
|
<p v-if="order.checkout.owner_adjustment_reason">号主修正:{{ order.checkout.owner_adjustment_reason }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="order" class="order-panel dashboard-panel code-panel">
|
<div v-if="order" class="order-panel dashboard-panel code-panel">
|
||||||
<h2>账号快照</h2>
|
<h2>账号快照</h2>
|
||||||
<pre>{{ snapshotText }}</pre>
|
<pre>{{ snapshotText }}</pre>
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
import { fetchAdminOrders, type Order } from '@/api/orders'
|
import { fetchAdminOrders, type OrderListItem } from '@/api/orders'
|
||||||
import { handoffStatusLabel, orderStatusLabel, settlementStatusLabel } from '@/utils/statusLabels'
|
import { handoffStatusLabel, orderStatusLabel, settlementStatusLabel } from '@/utils/statusLabels'
|
||||||
import { formatDateTime } from '@/utils/time'
|
import { formatDateTime } from '@/utils/time'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const orders = ref<Order[]>([])
|
const orders = ref<OrderListItem[]>([])
|
||||||
const status = ref('')
|
const status = ref('')
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const currentPageSize = ref(20)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
const filteredOrders = computed(() => {
|
const filteredOrders = computed(() => {
|
||||||
if (!status.value) return orders.value
|
if (!status.value) return orders.value
|
||||||
@@ -19,11 +22,18 @@ onMounted(loadOrders)
|
|||||||
async function loadOrders() {
|
async function loadOrders() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
orders.value = await fetchAdminOrders()
|
const result = await fetchAdminOrders(currentPage.value, currentPageSize.value)
|
||||||
|
orders.value = result.items
|
||||||
|
total.value = result.total
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSizeChange() {
|
||||||
|
currentPage.value = 1
|
||||||
|
loadOrders()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -39,7 +49,10 @@ async function loadOrders() {
|
|||||||
<el-option label="待交接" value="pending_handoff" />
|
<el-option label="待交接" value="pending_handoff" />
|
||||||
<el-option label="使用中" value="renting" />
|
<el-option label="使用中" value="renting" />
|
||||||
<el-option label="逾期中" value="overdue" />
|
<el-option label="逾期中" value="overdue" />
|
||||||
<el-option label="待归还确认" value="pending_return_confirm" />
|
<el-option label="待结账确认" value="pending_return_confirm" />
|
||||||
|
<el-option label="待号主确认结账" value="pending_checkout_confirm" />
|
||||||
|
<el-option label="待租客确认修正" value="pending_checkout_accept" />
|
||||||
|
<el-option label="结账争议中" value="checkout_disputing" />
|
||||||
<el-option label="申诉中" value="disputing" />
|
<el-option label="申诉中" value="disputing" />
|
||||||
<el-option label="异常" value="abnormal" />
|
<el-option label="异常" value="abnormal" />
|
||||||
<el-option label="已完成" value="completed" />
|
<el-option label="已完成" value="completed" />
|
||||||
@@ -52,20 +65,30 @@ async function loadOrders() {
|
|||||||
|
|
||||||
<el-table v-loading="loading" class="table-panel" :data="filteredOrders">
|
<el-table v-loading="loading" class="table-panel" :data="filteredOrders">
|
||||||
<el-table-column prop="order_no" label="订单号" min-width="230" />
|
<el-table-column prop="order_no" label="订单号" min-width="230" />
|
||||||
<el-table-column prop="title" label="账号" min-width="170" />
|
<el-table-column prop="listing_title" label="商品名" min-width="170" />
|
||||||
<el-table-column prop="renter_phone" label="租客" min-width="130" />
|
<el-table-column label="租客/号主" min-width="160">
|
||||||
<el-table-column prop="owner_phone" label="号主" min-width="130" />
|
<template #default="{ row }">
|
||||||
<el-table-column label="状态" width="140">
|
<span>{{ row.renter_nickname }}</span>
|
||||||
<template #default="{ row }">{{ orderStatusLabel(row.status) }}</template>
|
<span class="table-subtext">{{ row.owner_nickname }}</span>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="交接" width="180">
|
<el-table-column label="状态" min-width="160">
|
||||||
<template #default="{ row }">{{ handoffStatusLabel(row.handoff_status) }}</template>
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small">{{ orderStatusLabel(row.status) }}</el-tag>
|
||||||
|
<el-tag size="small" type="warning" v-if="row.handoff_status && row.handoff_status !== 'none'">
|
||||||
|
{{ handoffStatusLabel(row.handoff_status) }}
|
||||||
|
</el-tag>
|
||||||
|
<el-tag size="small" type="info" v-if="row.settlement_status && row.settlement_status !== 'unsettled'">
|
||||||
|
{{ settlementStatusLabel(row.settlement_status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="结算" width="120">
|
<el-table-column label="金额" min-width="130">
|
||||||
<template #default="{ row }">{{ settlementStatusLabel(row.settlement_status) }}</template>
|
<template #default="{ row }">
|
||||||
|
<span>¥{{ row.rent_amount }}</span>
|
||||||
|
<span class="table-subtext">押金 ¥{{ row.deposit_amount }}</span>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="rent_amount" label="订单金额" width="100" />
|
|
||||||
<el-table-column prop="deposit_amount" label="押金" width="100" />
|
|
||||||
<el-table-column label="创建时间" min-width="180">
|
<el-table-column label="创建时间" min-width="180">
|
||||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -77,5 +100,17 @@ async function loadOrders() {
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pagination-wrap" v-if="total > 0">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="currentPageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@current-change="loadOrders"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ElMessage } from 'element-plus'
|
|||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
|
|
||||||
import { fetchAdminUsers, freezeAdminUser, unfreezeAdminUser, type AdminUserItem } from '@/api/adminUsers'
|
import { fetchAdminUsers, freezeAdminUser, unfreezeAdminUser, type AdminUserItem } from '@/api/adminUsers'
|
||||||
import { realnameStatusLabel, riskStatusLabel, userStatusLabel } from '@/utils/statusLabels'
|
import { userStatusLabel } from '@/utils/statusLabels'
|
||||||
import { formatDateTime } from '@/utils/time'
|
import { formatDateTime } from '@/utils/time'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -11,18 +11,28 @@ const submitting = ref(false)
|
|||||||
const users = ref<AdminUserItem[]>([])
|
const users = ref<AdminUserItem[]>([])
|
||||||
const activeUser = ref<AdminUserItem | null>(null)
|
const activeUser = ref<AdminUserItem | null>(null)
|
||||||
const freezeReason = ref('')
|
const freezeReason = ref('')
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const currentPageSize = ref(20)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
onMounted(loadUsers)
|
onMounted(loadUsers)
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
users.value = await fetchAdminUsers()
|
const result = await fetchAdminUsers(currentPage.value, currentPageSize.value)
|
||||||
|
users.value = result.items
|
||||||
|
total.value = result.total
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSizeChange() {
|
||||||
|
currentPage.value = 1
|
||||||
|
loadUsers()
|
||||||
|
}
|
||||||
|
|
||||||
function openFreeze(row: AdminUserItem) {
|
function openFreeze(row: AdminUserItem) {
|
||||||
activeUser.value = row
|
activeUser.value = row
|
||||||
freezeReason.value = ''
|
freezeReason.value = ''
|
||||||
@@ -71,30 +81,20 @@ function readError(error: unknown, fallback: string) {
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<p class="eyebrow">Users</p>
|
<p class="eyebrow">Users</p>
|
||||||
<h1>用户管理</h1>
|
<h1>用户管理</h1>
|
||||||
<p>查看用户实名、风险、信用和业务数量,处理冻结与解冻。</p>
|
<p>查看用户信息和状态,处理冻结与解冻。</p>
|
||||||
</div>
|
</div>
|
||||||
<el-button @click="loadUsers">刷新</el-button>
|
<el-button @click="loadUsers">刷新</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" class="table-panel" :data="users">
|
<el-table v-loading="loading" class="table-panel" :data="users">
|
||||||
<el-table-column prop="id" label="ID" width="80" />
|
<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="nickname" label="昵称" min-width="130" />
|
||||||
<el-table-column label="实名" width="110">
|
<el-table-column prop="phone" label="手机号" min-width="130" />
|
||||||
<template #default="{ row }">{{ realnameStatusLabel(row.realname_status) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="风险" width="110">
|
|
||||||
<template #default="{ row }">{{ riskStatusLabel(row.risk_status) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="credit_score" label="信用分" width="100" />
|
|
||||||
<el-table-column label="状态" width="110">
|
<el-table-column label="状态" width="110">
|
||||||
<template #default="{ row }">{{ userStatusLabel(row.status) }}</template>
|
<template #default="{ row }">{{ userStatusLabel(row.status) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="order_count" label="订单" width="90" />
|
<el-table-column label="注册时间" min-width="180">
|
||||||
<el-table-column prop="listing_count" label="发布" width="90" />
|
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||||
<el-table-column prop="dispute_count" label="申诉" width="90" />
|
|
||||||
<el-table-column label="最近登录" min-width="180">
|
|
||||||
<template #default="{ row }">{{ formatDateTime(row.last_login_at) }}</template>
|
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="150">
|
<el-table-column label="操作" width="150">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -106,6 +106,18 @@ function readError(error: unknown, fallback: string) {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pagination-wrap" v-if="total > 0">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="currentPageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@current-change="loadUsers"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-dialog :model-value="!!activeUser" title="冻结用户" width="560px" @update:model-value="activeUser = null">
|
<el-dialog :model-value="!!activeUser" title="冻结用户" width="560px" @update:model-value="activeUser = null">
|
||||||
<div v-if="activeUser" class="dialog-body">
|
<div v-if="activeUser" class="dialog-body">
|
||||||
<p><strong>{{ activeUser.phone }}</strong> · {{ activeUser.nickname }}</p>
|
<p><strong>{{ activeUser.phone }}</strong> · {{ activeUser.nickname }}</p>
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import { formatDateTime } from '@/utils/time'
|
|||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const ledger = ref<AdminWalletLedger[]>([])
|
const ledger = ref<AdminWalletLedger[]>([])
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const currentPageSize = ref(20)
|
||||||
|
const total = ref(0)
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
user_id: '',
|
user_id: '',
|
||||||
order_id: '',
|
order_id: '',
|
||||||
biz_type: '',
|
biz_type: '',
|
||||||
limit: 200,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const inAmount = computed(() =>
|
const inAmount = computed(() =>
|
||||||
@@ -21,16 +23,20 @@ const inAmount = computed(() =>
|
|||||||
const outAmount = computed(() =>
|
const outAmount = computed(() =>
|
||||||
ledger.value.filter((item) => item.direction === 'out').reduce((sum, item) => sum + Number(item.amount || 0), 0),
|
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)
|
onMounted(loadLedger)
|
||||||
|
|
||||||
async function loadLedger() {
|
async function loadLedger() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
ledger.value = await fetchAdminWalletLedger(filters)
|
const query = {
|
||||||
|
...filters,
|
||||||
|
page: currentPage.value,
|
||||||
|
page_size: currentPageSize.value,
|
||||||
|
}
|
||||||
|
const result = await fetchAdminWalletLedger(query)
|
||||||
|
ledger.value = result.items
|
||||||
|
total.value = result.total
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -40,10 +46,15 @@ function resetFilters() {
|
|||||||
filters.user_id = ''
|
filters.user_id = ''
|
||||||
filters.order_id = ''
|
filters.order_id = ''
|
||||||
filters.biz_type = ''
|
filters.biz_type = ''
|
||||||
filters.limit = 200
|
currentPage.value = 1
|
||||||
void loadLedger()
|
void loadLedger()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSizeChange() {
|
||||||
|
currentPage.value = 1
|
||||||
|
loadLedger()
|
||||||
|
}
|
||||||
|
|
||||||
function money(value: number) {
|
function money(value: number) {
|
||||||
return `¥${value.toFixed(2)}`
|
return `¥${value.toFixed(2)}`
|
||||||
}
|
}
|
||||||
@@ -65,7 +76,7 @@ function directionLabel(direction: string) {
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<p class="eyebrow">Wallet Ledger</p>
|
<p class="eyebrow">Wallet Ledger</p>
|
||||||
<h1>资金流水</h1>
|
<h1>资金流水</h1>
|
||||||
<p>查看订单金额、押金、冻结、解冻、退款和结算流水。当前为开发态账务记录,不代表真实支付余额。</p>
|
<p>查看订单金额、押金、冻结、解冻、退款和结算流水。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
<el-button @click="resetFilters">重置</el-button>
|
<el-button @click="resetFilters">重置</el-button>
|
||||||
@@ -75,8 +86,8 @@ function directionLabel(direction: string) {
|
|||||||
|
|
||||||
<div class="metric-grid">
|
<div class="metric-grid">
|
||||||
<div class="metric-card">
|
<div class="metric-card">
|
||||||
<span>当前结果</span>
|
<span>总条数</span>
|
||||||
<strong>{{ ledger.length }} 笔</strong>
|
<strong>{{ total }} 笔</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-card">
|
<div class="metric-card">
|
||||||
<span>入账合计</span>
|
<span>入账合计</span>
|
||||||
@@ -86,10 +97,6 @@ function directionLabel(direction: string) {
|
|||||||
<span>出账合计</span>
|
<span>出账合计</span>
|
||||||
<strong>{{ money(outAmount) }}</strong>
|
<strong>{{ money(outAmount) }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-card">
|
|
||||||
<span>冻结相关金额</span>
|
|
||||||
<strong>{{ money(frozenAmount) }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-form class="filter-panel" label-position="top">
|
<el-form class="filter-panel" label-position="top">
|
||||||
@@ -107,20 +114,16 @@ function directionLabel(direction: string) {
|
|||||||
<el-option label="押金退回" value="deposit_refund" />
|
<el-option label="押金退回" value="deposit_refund" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</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-form>
|
||||||
|
|
||||||
<el-table v-loading="loading" class="table-panel" :data="ledger">
|
<el-table v-loading="loading" class="table-panel" :data="ledger">
|
||||||
<el-table-column prop="ledger_no" label="流水号" min-width="230" />
|
<el-table-column prop="ledger_no" label="流水号" min-width="230" />
|
||||||
<el-table-column label="用户" min-width="160">
|
<el-table-column label="用户" min-width="130">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<strong>{{ row.user_phone || `用户 ${row.user_id}` }}</strong>
|
<strong>{{ row.user_phone || `用户 ${row.user_id}` }}</strong>
|
||||||
<span class="table-subtext">ID: {{ row.user_id }}</span>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="订单" min-width="180">
|
<el-table-column label="订单" min-width="160">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<RouterLink v-if="row.order_id" :to="`/admin/orders/${row.order_id}`">{{ row.order_no || row.order_id }}</RouterLink>
|
<RouterLink v-if="row.order_id" :to="`/admin/orders/${row.order_id}`">{{ row.order_no || row.order_id }}</RouterLink>
|
||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
@@ -141,10 +144,21 @@ function directionLabel(direction: string) {
|
|||||||
<el-table-column label="变化后余额" width="130">
|
<el-table-column label="变化后余额" width="130">
|
||||||
<template #default="{ row }">{{ money(Number(row.balance_after)) }}</template>
|
<template #default="{ row }">{{ money(Number(row.balance_after)) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="remark" label="备注" min-width="220" />
|
|
||||||
<el-table-column label="创建时间" min-width="180">
|
<el-table-column label="创建时间" min-width="180">
|
||||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pagination-wrap" v-if="total > 0">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="currentPageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@current-change="loadLedger"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import type { Listing } from '@/api/listings'
|
||||||
|
import {
|
||||||
|
assetRegions,
|
||||||
|
formatHafCoinM,
|
||||||
|
getCoinWan,
|
||||||
|
getListingResources,
|
||||||
|
getListingTitle,
|
||||||
|
getOnlineTimeText,
|
||||||
|
hasAcceleratedSaleRatio,
|
||||||
|
hasGiftResources,
|
||||||
|
readAssetNumber,
|
||||||
|
readAssetString,
|
||||||
|
} from '@/views/mobile/listingDisplay'
|
||||||
|
|
||||||
|
export interface AdminListingPricing {
|
||||||
|
buyerDaily: number
|
||||||
|
sellerDaily: number
|
||||||
|
sellerRatio: number
|
||||||
|
buyerRatio: number
|
||||||
|
platformMarkup: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ownerName(row: Listing) {
|
||||||
|
return row.owner_phone || row.owner_nickname || `号主 ${row.owner_id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listingDisplayTitle(row: Listing) {
|
||||||
|
return getListingTitle(row) || row.title
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMoney(value: number) {
|
||||||
|
return `¥${Number(value || 0).toFixed(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdminListingPricing(row: Listing): AdminListingPricing {
|
||||||
|
const breakdown = readBreakdown(row)
|
||||||
|
const buyerDaily = readBreakdownNumber(breakdown, 'buyer_total_price') || Number(row.price_daily || row.price_hourly || 0)
|
||||||
|
const sellerDaily = readBreakdownNumber(breakdown, 'seller_total_price') || buyerDaily
|
||||||
|
return {
|
||||||
|
buyerDaily,
|
||||||
|
sellerDaily,
|
||||||
|
sellerRatio: readBreakdownNumber(breakdown, 'seller_ratio'),
|
||||||
|
buyerRatio: readBreakdownNumber(breakdown, 'buyer_ratio') || readAssetNumber(row, 'publish_ratio'),
|
||||||
|
platformMarkup: readBreakdownNumber(breakdown, 'platform_markup_amount'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listingBuyerPrice(row: Listing) {
|
||||||
|
return formatMoney(getAdminListingPricing(row).buyerDaily)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listingSellerPrice(row: Listing) {
|
||||||
|
return formatMoney(getAdminListingPricing(row).sellerDaily)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRatio(value: number) {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return '-'
|
||||||
|
const rounded = Math.round(value * 100) / 100
|
||||||
|
return `1:${Number.isInteger(rounded) ? rounded : rounded.toFixed(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listingAssetTags(row: Listing) {
|
||||||
|
return [
|
||||||
|
`哈夫币:${formatHafCoinM(getCoinWan(row))}`,
|
||||||
|
tagValue('保险', readAssetString(row, 'season_insurance')),
|
||||||
|
tagValue('体力', readAssetString(row, 'stamina_level')),
|
||||||
|
tagValue('负重', readAssetString(row, 'load_level')),
|
||||||
|
tagValue('段位', row.rank_level),
|
||||||
|
tagValue('日损', formatDailyLoss(row)),
|
||||||
|
tagValue('烽火', readAssetNumber(row, 'fire_level') || ''),
|
||||||
|
tagValue('KD', readAssetString(row, 'secret_kd')),
|
||||||
|
].filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listingRiskTags(row: Listing) {
|
||||||
|
return [
|
||||||
|
tagValue('人脸', readAssetString(row, 'face_owner')),
|
||||||
|
tagValue('封禁', readAssetString(row, 'ban_record')),
|
||||||
|
tagValue('地区', assetRegions(row).join('/')),
|
||||||
|
tagValue('在线', getOnlineTimeText(row)),
|
||||||
|
].filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listingResourceTags(row: Listing, limit = 4) {
|
||||||
|
const resources = getListingResources(row).map((resource) => {
|
||||||
|
const mode = resource.mode === '赠送' ? '赠送' : '收费'
|
||||||
|
return `${resource.label}:${resource.quantity}${mode === '赠送' ? '(赠)' : ''}`
|
||||||
|
})
|
||||||
|
if (resources.length <= limit) return resources
|
||||||
|
return [...resources.slice(0, limit), `+${resources.length - limit}项`]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listingBadgeTags(row: Listing) {
|
||||||
|
return [
|
||||||
|
hasAcceleratedSaleRatio(row) ? '特惠' : '',
|
||||||
|
hasGiftResources(row) ? '有赠送' : '',
|
||||||
|
].filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDailyLoss(row: Listing) {
|
||||||
|
const value = readAssetNumber(row, 'daily_loss_m')
|
||||||
|
return value > 0 ? `${value}M/天` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function tagValue(label: string, value: string | number) {
|
||||||
|
const text = String(value || '').trim()
|
||||||
|
return text ? `${label}:${text}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBreakdown(row: Listing) {
|
||||||
|
const breakdown = row.asset_summary?.price_breakdown
|
||||||
|
return typeof breakdown === 'object' && breakdown !== null ? (breakdown as Record<string, unknown>) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBreakdownNumber(row: Record<string, unknown> | null, key: string) {
|
||||||
|
if (!row) return 0
|
||||||
|
const value = row[key]
|
||||||
|
if (typeof value === 'number') return Number.isFinite(value) ? value : 0
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
const parsed = Number(value)
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -58,7 +58,10 @@ const sortOptions = [
|
|||||||
{ key: "priceDesc", label: "价格最高" },
|
{ key: "priceDesc", label: "价格最高" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const rangePresets: Record<string, Array<{ label: string; min: string; max: string }>> = {
|
const rangePresets: Record<
|
||||||
|
string,
|
||||||
|
Array<{ label: string; min: string; max: string }>
|
||||||
|
> = {
|
||||||
coin: [
|
coin: [
|
||||||
{ label: "50-100", min: "50", max: "100" },
|
{ label: "50-100", min: "50", max: "100" },
|
||||||
{ label: "100-200", min: "100", max: "200" },
|
{ label: "100-200", min: "100", max: "200" },
|
||||||
@@ -98,13 +101,52 @@ const loginMethodFilterOptions = computed(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const filterSections = computed<FilterSection[]>(() => [
|
const filterSections = computed<FilterSection[]>(() => [
|
||||||
{ key: "price", title: "价格区间", type: "range", unit: "元", minPlaceholder: "最低价", maxPlaceholder: "最高价" },
|
{
|
||||||
{ key: "coin", title: "哈夫币数量", type: "range", unit: "M", minPlaceholder: "最低", maxPlaceholder: "最高" },
|
key: "price",
|
||||||
{ key: "server", title: "区服", type: "chips", options: serverFilterOptions.value },
|
title: "价格区间",
|
||||||
{ key: "login", title: "上号方式", type: "chips", options: loginMethodFilterOptions.value },
|
type: "range",
|
||||||
{ key: "insurance", title: "保险", type: "chips", options: publishOptions.value.insurance_options },
|
unit: "元",
|
||||||
{ key: "stamina", title: "体力", type: "chips", options: publishOptions.value.level_options },
|
minPlaceholder: "最低价",
|
||||||
{ key: "load", title: "负重", type: "chips", options: publishOptions.value.level_options },
|
maxPlaceholder: "最高价",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "coin",
|
||||||
|
title: "哈夫币数量",
|
||||||
|
type: "range",
|
||||||
|
unit: "M",
|
||||||
|
minPlaceholder: "最低",
|
||||||
|
maxPlaceholder: "最高",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "server",
|
||||||
|
title: "区服",
|
||||||
|
type: "chips",
|
||||||
|
options: serverFilterOptions.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "login",
|
||||||
|
title: "上号方式",
|
||||||
|
type: "chips",
|
||||||
|
options: loginMethodFilterOptions.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "insurance",
|
||||||
|
title: "保险",
|
||||||
|
type: "chips",
|
||||||
|
options: publishOptions.value.insurance_options,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "stamina",
|
||||||
|
title: "体力",
|
||||||
|
type: "chips",
|
||||||
|
options: publishOptions.value.level_options,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "load",
|
||||||
|
title: "负重",
|
||||||
|
type: "chips",
|
||||||
|
options: publishOptions.value.level_options,
|
||||||
|
},
|
||||||
...publishOptions.value.quantity_items.map((item) => ({
|
...publishOptions.value.quantity_items.map((item) => ({
|
||||||
key: `resource_${item.key}`,
|
key: `resource_${item.key}`,
|
||||||
title: item.label,
|
title: item.label,
|
||||||
@@ -119,9 +161,27 @@ const filterSections = computed<FilterSection[]>(() => [
|
|||||||
type: "chips" as const,
|
type: "chips" as const,
|
||||||
options: group.options,
|
options: group.options,
|
||||||
})),
|
})),
|
||||||
{ key: "secretKd", title: "绝密KD", type: "range", minPlaceholder: "最低", maxPlaceholder: "最高" },
|
{
|
||||||
{ key: "rank", title: "段位", type: "chips", options: publishOptions.value.rank_options },
|
key: "secretKd",
|
||||||
{ key: "deposit", title: "押金", type: "range", unit: "元", minPlaceholder: "最低", maxPlaceholder: "最高" },
|
title: "绝密KD",
|
||||||
|
type: "range",
|
||||||
|
minPlaceholder: "最低",
|
||||||
|
maxPlaceholder: "最高",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "rank",
|
||||||
|
title: "段位",
|
||||||
|
type: "chips",
|
||||||
|
options: publishOptions.value.rank_options,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "deposit",
|
||||||
|
title: "押金",
|
||||||
|
type: "range",
|
||||||
|
unit: "元",
|
||||||
|
minPlaceholder: "最低",
|
||||||
|
maxPlaceholder: "最高",
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const activeFilterCount = computed(() => {
|
const activeFilterCount = computed(() => {
|
||||||
@@ -160,7 +220,8 @@ async function loadListings() {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
loadFailed.value = false;
|
loadFailed.value = false;
|
||||||
try {
|
try {
|
||||||
listings.value = await fetchListings();
|
const res = await fetchListings();
|
||||||
|
listings.value = res.items;
|
||||||
} catch {
|
} catch {
|
||||||
loadFailed.value = true;
|
loadFailed.value = true;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -188,7 +249,7 @@ async function onRefresh() {
|
|||||||
fetchListings(),
|
fetchListings(),
|
||||||
fetchMobileHomeConfig(),
|
fetchMobileHomeConfig(),
|
||||||
]);
|
]);
|
||||||
listings.value = nextListings;
|
listings.value = nextListings.items;
|
||||||
announcements.value = nextHomeConfig.announcements;
|
announcements.value = nextHomeConfig.announcements;
|
||||||
bannerSlides.value = nextHomeConfig.banners;
|
bannerSlides.value = nextHomeConfig.banners;
|
||||||
publishOptions.value = nextHomeConfig.publish_options;
|
publishOptions.value = nextHomeConfig.publish_options;
|
||||||
@@ -243,19 +304,24 @@ function sortListings(items: Listing[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function matchesFilters(item: Listing) {
|
function matchesFilters(item: Listing) {
|
||||||
const chipOk = Object.entries(selectedFilters.value).every(([key, values]) => {
|
const chipOk = Object.entries(selectedFilters.value).every(
|
||||||
if (!values.length) return true;
|
([key, values]) => {
|
||||||
if (key === "server") return values.includes(getServerRegion(item));
|
if (!values.length) return true;
|
||||||
if (key === "login") return values.includes(getLoginMethod(item));
|
if (key === "server") return values.includes(getServerRegion(item));
|
||||||
if (key === "insurance") return values.includes(readAssetString(item, "season_insurance"));
|
if (key === "login") return values.includes(getLoginMethod(item));
|
||||||
if (key === "stamina") return values.includes(readAssetString(item, "stamina_level"));
|
if (key === "insurance")
|
||||||
if (key === "load") return values.includes(readAssetString(item, "load_level"));
|
return values.includes(readAssetString(item, "season_insurance"));
|
||||||
if (key === "rank") return values.includes(item.rank_level);
|
if (key === "stamina")
|
||||||
if (isSkinGroupKey(key)) {
|
return values.includes(readAssetString(item, "stamina_level"));
|
||||||
return getSkinGroup(item, key).some((skin) => values.includes(skin));
|
if (key === "load")
|
||||||
|
return values.includes(readAssetString(item, "load_level"));
|
||||||
|
if (key === "rank") return values.includes(item.rank_level);
|
||||||
|
if (isSkinGroupKey(key)) {
|
||||||
|
return getSkinGroup(item, key).some((skin) => values.includes(skin));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
return true;
|
);
|
||||||
});
|
|
||||||
if (!chipOk) return false;
|
if (!chipOk) return false;
|
||||||
|
|
||||||
return Object.entries(rangeFilters.value).every(([key, range]) => {
|
return Object.entries(rangeFilters.value).every(([key, range]) => {
|
||||||
@@ -312,7 +378,6 @@ function parseQuantityUnit(price: string) {
|
|||||||
function uniqueOptions(values: string[]) {
|
function uniqueOptions(values: string[]) {
|
||||||
return [...new Set(values.map((item) => item.trim()).filter(Boolean))];
|
return [...new Set(values.map((item) => item.trim()).filter(Boolean))];
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -378,7 +443,9 @@ function uniqueOptions(values: string[]) {
|
|||||||
<h1 v-if="slide.title">{{ slide.title }}</h1>
|
<h1 v-if="slide.title">{{ slide.title }}</h1>
|
||||||
<span v-if="slide.pill">{{ slide.pill }}</span>
|
<span v-if="slide.pill">{{ slide.pill }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="slide.badge" class="banner-badge">{{ slide.badge }}</div>
|
<div v-if="slide.badge" class="banner-badge">
|
||||||
|
{{ slide.badge }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</van-swipe-item>
|
</van-swipe-item>
|
||||||
</van-swipe>
|
</van-swipe>
|
||||||
|
|||||||
@@ -3,14 +3,17 @@ import { onMounted, ref, computed } from "vue";
|
|||||||
import { useRouter, useRoute } from "vue-router";
|
import { useRouter, useRoute } from "vue-router";
|
||||||
import { showToast } from "vant";
|
import { showToast } from "vant";
|
||||||
|
|
||||||
import { fetchOrders, type Order } from "@/api/orders";
|
import { fetchOrders, type OrderListItem } from "@/api/orders";
|
||||||
import { formatDateMinute } from "@/utils/time";
|
import { formatDateMinute } from "@/utils/time";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const orders = ref<Order[]>([]);
|
const orders = ref<OrderListItem[]>([]);
|
||||||
const activeTab = ref("all");
|
const activeTab = ref("all");
|
||||||
|
const currentPage = ref(1);
|
||||||
|
const currentPageSize = ref(20);
|
||||||
|
const total = ref(0);
|
||||||
|
|
||||||
/** 判断当前底部导航是否激活 */
|
/** 判断当前底部导航是否激活 */
|
||||||
function isNavActive(path: string) {
|
function isNavActive(path: string) {
|
||||||
@@ -23,7 +26,9 @@ onMounted(loadOrders);
|
|||||||
async function loadOrders() {
|
async function loadOrders() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
orders.value = await fetchOrders();
|
const result = await fetchOrders(currentPage.value, currentPageSize.value);
|
||||||
|
orders.value = result.items;
|
||||||
|
total.value = result.total;
|
||||||
} catch {
|
} catch {
|
||||||
showToast({ message: "订单加载失败,请稍后重试", icon: "warning-o" });
|
showToast({ message: "订单加载失败,请稍后重试", icon: "warning-o" });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -31,12 +36,19 @@ async function loadOrders() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleLoadMore() {
|
||||||
|
if (orders.value.length < total.value) {
|
||||||
|
currentPage.value++;
|
||||||
|
loadOrders();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* 状态筛选 */
|
/* 状态筛选 */
|
||||||
const statusTabs = [
|
const statusTabs = [
|
||||||
{ key: "all", label: "全部" },
|
{ key: "all", label: "全部" },
|
||||||
{ key: "pending_handoff", label: "待交接" },
|
{ key: "pending_handoff", label: "待交接" },
|
||||||
{ key: "renting", label: "使用中" },
|
{ key: "renting", label: "使用中" },
|
||||||
{ key: "pending_return_confirm", label: "待归还" },
|
{ key: "pending_checkout_confirm", label: "待结账" },
|
||||||
{ key: "completed", label: "已完成" },
|
{ key: "completed", label: "已完成" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -52,6 +64,9 @@ function statusColor(status: string) {
|
|||||||
renting: "#1477ff",
|
renting: "#1477ff",
|
||||||
overdue: "#e91e63",
|
overdue: "#e91e63",
|
||||||
pending_return_confirm: "#e91e63",
|
pending_return_confirm: "#e91e63",
|
||||||
|
pending_checkout_confirm: "#e91e63",
|
||||||
|
pending_checkout_accept: "#9c27b0",
|
||||||
|
checkout_disputing: "#f44336",
|
||||||
completed: "#4caf50",
|
completed: "#4caf50",
|
||||||
cancelled: "#999",
|
cancelled: "#999",
|
||||||
disputing: "#f44336",
|
disputing: "#f44336",
|
||||||
@@ -66,7 +81,10 @@ function statusLabel(status: string) {
|
|||||||
pending_handoff: "待交接",
|
pending_handoff: "待交接",
|
||||||
renting: "使用中",
|
renting: "使用中",
|
||||||
overdue: "已逾期",
|
overdue: "已逾期",
|
||||||
pending_return_confirm: "待归还",
|
pending_return_confirm: "待结账",
|
||||||
|
pending_checkout_confirm: "待号主确认结账",
|
||||||
|
pending_checkout_accept: "待租客确认修正",
|
||||||
|
checkout_disputing: "结账争议中",
|
||||||
completed: "已完成",
|
completed: "已完成",
|
||||||
cancelled: "已取消",
|
cancelled: "已取消",
|
||||||
disputing: "申诉中",
|
disputing: "申诉中",
|
||||||
@@ -135,7 +153,7 @@ function goDetail(id: number) {
|
|||||||
|
|
||||||
<!-- 卡片体:关键信息 -->
|
<!-- 卡片体:关键信息 -->
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h3 class="order-title">{{ order.title }}</h3>
|
<h3 class="order-title">{{ order.listing_title }}</h3>
|
||||||
<div class="info-grid">
|
<div class="info-grid">
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="info-label">订单金额</span>
|
<span class="info-label">订单金额</span>
|
||||||
@@ -146,8 +164,8 @@ function goDetail(id: number) {
|
|||||||
<span class="info-value">¥{{ order.deposit_amount }}</span>
|
<span class="info-value">¥{{ order.deposit_amount }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="info-label">区服</span>
|
<span class="info-label">游戏</span>
|
||||||
<span class="info-value">{{ order.server_region }}</span>
|
<span class="info-value">{{ order.game_name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,6 +176,13 @@ function goDetail(id: number) {
|
|||||||
<span class="detail-link">查看详情 ›</span>
|
<span class="detail-link">查看详情 ›</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 加载更多 -->
|
||||||
|
<div v-if="orders.length < total && !loading" class="load-more-wrap">
|
||||||
|
<van-button size="small" type="primary" plain @click="handleLoadMore">
|
||||||
|
加载更多 ({{ orders.length }}/{{ total }})
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 底部导航 -->
|
<!-- 底部导航 -->
|
||||||
@@ -390,6 +415,12 @@ function goDetail(id: number) {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.load-more-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ========== 底部导航 ========== */
|
/* ========== 底部导航 ========== */
|
||||||
.bottom-nav {
|
.bottom-nav {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -78,11 +78,14 @@ const visibleListings = computed(() => {
|
|||||||
.join(" ")
|
.join(" ")
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
if (keyword && !text.includes(keyword)) return false;
|
if (keyword && !text.includes(keyword)) return false;
|
||||||
if (filters.server && getServerRegion(item) !== filters.server) return false;
|
if (filters.server && getServerRegion(item) !== filters.server)
|
||||||
if (filters.loginMethod && getLoginMethod(item) !== filters.loginMethod) return false;
|
return false;
|
||||||
|
if (filters.loginMethod && getLoginMethod(item) !== filters.loginMethod)
|
||||||
|
return false;
|
||||||
if (filters.rank && item.rank_level !== filters.rank) return false;
|
if (filters.rank && item.rank_level !== filters.rank) return false;
|
||||||
if (filters.minCoin && getCoinM(item) < filters.minCoin) return false;
|
if (filters.minCoin && getCoinM(item) < filters.minCoin) return false;
|
||||||
if (filters.maxPrice && getListingDisplayPrice(item) > filters.maxPrice) return false;
|
if (filters.maxPrice && getListingDisplayPrice(item) > filters.maxPrice)
|
||||||
|
return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -103,7 +106,11 @@ const visibleListings = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const statCards = computed(() => [
|
const statCards = computed(() => [
|
||||||
{ label: "可租账号", value: `${visibleListings.value.length}`, hint: "当前筛选结果" },
|
{
|
||||||
|
label: "可租账号",
|
||||||
|
value: `${visibleListings.value.length}`,
|
||||||
|
hint: "当前筛选结果",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "高哈夫币",
|
label: "高哈夫币",
|
||||||
value: `${listings.value.filter((item) => getCoinM(item) >= 100).length}`,
|
value: `${listings.value.filter((item) => getCoinM(item) >= 100).length}`,
|
||||||
@@ -111,7 +118,10 @@ const statCards = computed(() => [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "账密登录",
|
label: "账密登录",
|
||||||
value: `${listings.value.filter((item) => getLoginMethod(item) === "账号密码").length}`,
|
value: `${
|
||||||
|
listings.value.filter((item) => getLoginMethod(item) === "账号密码")
|
||||||
|
.length
|
||||||
|
}`,
|
||||||
hint: "交接更快",
|
hint: "交接更快",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -125,7 +135,7 @@ async function loadHome() {
|
|||||||
fetchListings(),
|
fetchListings(),
|
||||||
fetchMobileHomeConfig(),
|
fetchMobileHomeConfig(),
|
||||||
]);
|
]);
|
||||||
listings.value = nextListings;
|
listings.value = nextListings.items;
|
||||||
announcements.value = config.announcements;
|
announcements.value = config.announcements;
|
||||||
banners.value = config.banners;
|
banners.value = config.banners;
|
||||||
publishOptions.value = config.publish_options;
|
publishOptions.value = config.publish_options;
|
||||||
@@ -165,7 +175,10 @@ function uniqueOptions(values: string[]) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="home-searchbar">
|
<div class="home-searchbar">
|
||||||
<el-icon><Search /></el-icon>
|
<el-icon><Search /></el-icon>
|
||||||
<input v-model="filters.keyword" placeholder="搜区服 / 段位 / 哈夫币 / 皮肤" />
|
<input
|
||||||
|
v-model="filters.keyword"
|
||||||
|
placeholder="搜区服 / 段位 / 哈夫币 / 皮肤"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<RouterLink class="home-service" to="/notifications">
|
<RouterLink class="home-service" to="/notifications">
|
||||||
<el-icon><Headset /></el-icon>
|
<el-icon><Headset /></el-icon>
|
||||||
@@ -205,13 +218,27 @@ function uniqueOptions(values: string[]) {
|
|||||||
<label>
|
<label>
|
||||||
<span>区服</span>
|
<span>区服</span>
|
||||||
<el-select v-model="filters.server" clearable placeholder="全部区服">
|
<el-select v-model="filters.server" clearable placeholder="全部区服">
|
||||||
<el-option v-for="item in serverOptions" :key="item" :label="item" :value="item" />
|
<el-option
|
||||||
|
v-for="item in serverOptions"
|
||||||
|
:key="item"
|
||||||
|
:label="item"
|
||||||
|
:value="item"
|
||||||
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>上号方式</span>
|
<span>上号方式</span>
|
||||||
<el-select v-model="filters.loginMethod" clearable placeholder="全部方式">
|
<el-select
|
||||||
<el-option v-for="item in loginMethodOptions" :key="item" :label="item" :value="item" />
|
v-model="filters.loginMethod"
|
||||||
|
clearable
|
||||||
|
placeholder="全部方式"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in loginMethodOptions"
|
||||||
|
:key="item"
|
||||||
|
:label="item"
|
||||||
|
:value="item"
|
||||||
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -249,18 +276,30 @@ function uniqueOptions(values: string[]) {
|
|||||||
<div class="quick-chip-group">
|
<div class="quick-chip-group">
|
||||||
<button type="button" @click="filters.minCoin = 100">100M+</button>
|
<button type="button" @click="filters.minCoin = 100">100M+</button>
|
||||||
<button type="button" @click="filters.minCoin = 300">300M+</button>
|
<button type="button" @click="filters.minCoin = 300">300M+</button>
|
||||||
<button type="button" @click="filters.loginMethod = '账号密码'">账密优先</button>
|
<button type="button" @click="filters.loginMethod = '账号密码'">
|
||||||
|
账密优先
|
||||||
|
</button>
|
||||||
<button type="button" @click="filters.server = 'QQ'">QQ 区服</button>
|
<button type="button" @click="filters.server = 'QQ'">QQ 区服</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="home-main">
|
<main class="home-main">
|
||||||
<section class="hero-board">
|
<section class="hero-board">
|
||||||
<el-carousel height="210px" indicator-position="outside" :interval="3600">
|
<el-carousel
|
||||||
<el-carousel-item v-for="slide in banners" :key="slide.title || slide.image_url">
|
height="210px"
|
||||||
|
indicator-position="outside"
|
||||||
|
:interval="3600"
|
||||||
|
>
|
||||||
|
<el-carousel-item
|
||||||
|
v-for="slide in banners"
|
||||||
|
:key="slide.title || slide.image_url"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="hero-slide"
|
class="hero-slide"
|
||||||
:class="[`tone-${slide.tone}`, { 'has-image': slide.image_url }]"
|
:class="[
|
||||||
|
`tone-${slide.tone}`,
|
||||||
|
{ 'has-image': slide.image_url },
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="slide.image_url"
|
v-if="slide.image_url"
|
||||||
@@ -335,7 +374,13 @@ function uniqueOptions(values: string[]) {
|
|||||||
<div class="desktop-title">
|
<div class="desktop-title">
|
||||||
<strong>{{ getListingTitle(item) }}</strong>
|
<strong>{{ getListingTitle(item) }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<p>{{ getListingSubtitle(item) || item.description || "平台担保交接,资料以截图为准。" }}</p>
|
<p>
|
||||||
|
{{
|
||||||
|
getListingSubtitle(item) ||
|
||||||
|
item.description ||
|
||||||
|
"平台担保交接,资料以截图为准。"
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
<div class="desktop-tags">
|
<div class="desktop-tags">
|
||||||
<span>{{ getServerRegion(item) }}</span>
|
<span>{{ getServerRegion(item) }}</span>
|
||||||
<span>{{ getLoginMethod(item) }}</span>
|
<span>{{ getLoginMethod(item) }}</span>
|
||||||
@@ -598,7 +643,11 @@ function uniqueOptions(values: string[]) {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
content: "";
|
content: "";
|
||||||
background: linear-gradient(90deg, rgba(10, 18, 32, 0.68), rgba(10, 18, 32, 0.12));
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(10, 18, 32, 0.68),
|
||||||
|
rgba(10, 18, 32, 0.12)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-copy {
|
.hero-copy {
|
||||||
|
|||||||
@@ -1,112 +1,100 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from "vue";
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { Refresh, Search } from "@element-plus/icons-vue";
|
import { Refresh, Search } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
import { fetchListings, type Listing } from "@/api/listings";
|
import { fetchListings, type Listing } from '@/api/listings'
|
||||||
import { listingStatusLabel } from "@/utils/statusLabels";
|
import { listingStatusLabel } from '@/utils/statusLabels'
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false)
|
||||||
const listings = ref<Listing[]>([]);
|
const listings = ref<Listing[]>([])
|
||||||
const sortBy = ref("recommended");
|
const sortBy = ref('recommended')
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const currentPageSize = ref(20)
|
||||||
|
const total = ref(0)
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
keyword: "",
|
keyword: '',
|
||||||
region: "",
|
region: '',
|
||||||
platform: "",
|
platform: '',
|
||||||
minCoin: undefined as number | undefined,
|
minCoin: undefined as number | undefined,
|
||||||
maxPrice: undefined as number | undefined,
|
maxPrice: undefined as number | undefined,
|
||||||
});
|
})
|
||||||
|
|
||||||
const regionOptions = computed(() =>
|
const regionOptions = computed(() =>
|
||||||
uniqueOptions(listings.value.map((item) => item.server_region))
|
uniqueOptions(listings.value.map((item) => item.server_region))
|
||||||
);
|
)
|
||||||
const platformOptions = computed(() =>
|
const platformOptions = computed(() =>
|
||||||
uniqueOptions(listings.value.map((item) => item.login_platform))
|
uniqueOptions(listings.value.map((item) => item.login_platform))
|
||||||
);
|
)
|
||||||
const filteredListings = computed(() => {
|
|
||||||
const keyword = filters.keyword.trim().toLowerCase();
|
|
||||||
const result = listings.value.filter((item) => {
|
|
||||||
const matchKeyword =
|
|
||||||
!keyword ||
|
|
||||||
[item.title, item.description, item.rank_level, item.server_region]
|
|
||||||
.filter(Boolean)
|
|
||||||
.some((value) => String(value).toLowerCase().includes(keyword));
|
|
||||||
const matchRegion =
|
|
||||||
!filters.region || item.server_region === filters.region;
|
|
||||||
const matchPlatform =
|
|
||||||
!filters.platform || item.login_platform === filters.platform;
|
|
||||||
const matchCoin =
|
|
||||||
!filters.minCoin || item.haf_coin_amount >= filters.minCoin;
|
|
||||||
const matchPrice =
|
|
||||||
!filters.maxPrice || listingPrice(item) <= filters.maxPrice;
|
|
||||||
return (
|
|
||||||
matchKeyword && matchRegion && matchPlatform && matchCoin && matchPrice
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return [...result].sort((a, b) => {
|
onMounted(loadListings)
|
||||||
if (sortBy.value === "price-asc") return listingPrice(a) - listingPrice(b);
|
|
||||||
if (sortBy.value === "coin-desc")
|
|
||||||
return b.haf_coin_amount - a.haf_coin_amount;
|
|
||||||
if (sortBy.value === "deposit-asc")
|
|
||||||
return a.deposit_amount - b.deposit_amount;
|
|
||||||
return (
|
|
||||||
b.haf_coin_amount - a.haf_coin_amount || listingPrice(a) - listingPrice(b)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(loadListings);
|
|
||||||
|
|
||||||
async function loadListings() {
|
async function loadListings() {
|
||||||
loading.value = true;
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
listings.value = await fetchListings();
|
// Pass filter params as query params to backend for server-side filtering
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
page: currentPage.value,
|
||||||
|
page_size: currentPageSize.value,
|
||||||
|
sort_by: sortBy.value,
|
||||||
|
}
|
||||||
|
if (filters.keyword) params.keyword = filters.keyword
|
||||||
|
if (filters.region) params.game_name = filters.region
|
||||||
|
if (filters.platform) params.platform = filters.platform
|
||||||
|
if (filters.minCoin) params.min_coin = filters.minCoin
|
||||||
|
if (filters.maxPrice) params.max_price = filters.maxPrice
|
||||||
|
|
||||||
|
const result = await fetchListings(currentPage.value, currentPageSize.value)
|
||||||
|
listings.value = result.items
|
||||||
|
total.value = result.total
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSizeChange() {
|
||||||
|
currentPage.value = 1
|
||||||
|
loadListings()
|
||||||
|
}
|
||||||
|
|
||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
filters.keyword = "";
|
filters.keyword = ''
|
||||||
filters.region = "";
|
filters.region = ''
|
||||||
filters.platform = "";
|
filters.platform = ''
|
||||||
filters.minCoin = undefined;
|
filters.minCoin = undefined
|
||||||
filters.maxPrice = undefined;
|
filters.maxPrice = undefined
|
||||||
sortBy.value = "recommended";
|
sortBy.value = 'recommended'
|
||||||
|
currentPage.value = 1
|
||||||
|
loadListings()
|
||||||
}
|
}
|
||||||
|
|
||||||
function uniqueOptions(values: string[]) {
|
function uniqueOptions(values: string[]) {
|
||||||
return [...new Set(values.filter(Boolean))];
|
return [...new Set(values.filter(Boolean))]
|
||||||
}
|
}
|
||||||
|
|
||||||
function listingPrice(item: Listing) {
|
function listingPrice(item: Listing) {
|
||||||
return Number(item.price_daily || item.price_hourly || 0);
|
return Number(item.price_daily || item.price_hourly || 0)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="pc-listings page">
|
<section class="pc-listings page">
|
||||||
<div class="anti-fraud-strip compact">
|
<div class="anti-fraud-strip compact">
|
||||||
<span
|
<span>防骗提示:只在平台内沟通、下单和确认结账,谨防冒充客服与低价私单。</span>
|
||||||
>防骗提示:只在平台内沟通、下单和确认归还,谨防冒充客服与低价私单。</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="page-header-row listings-header">
|
<div class="page-header-row listings-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Account Market</p>
|
<p class="eyebrow">Account Market</p>
|
||||||
<h1>租号大厅</h1>
|
<h1>租号大厅</h1>
|
||||||
<p>参考大锤商行 PC 信息密度,支持高级筛选、排序和卡片式资源浏览。</p>
|
<p>支持高级筛选、排序和卡片式资源浏览。</p>
|
||||||
</div>
|
</div>
|
||||||
<RouterLink class="pc-post-link large" to="/seller/listings/create"
|
<RouterLink class="pc-post-link large" to="/seller/listings/create">账号发布</RouterLink>
|
||||||
>账号发布</RouterLink
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="advanced-filter-card">
|
<section class="advanced-filter-card">
|
||||||
<div class="filter-title">
|
<div class="filter-title">
|
||||||
<strong>高级筛选</strong>
|
<strong>高级筛选</strong>
|
||||||
<span>共 {{ filteredListings.length }} 个可租账号</span>
|
<span>共 {{ total }} 个可租账号</span>
|
||||||
</div>
|
</div>
|
||||||
<el-form label-position="top" class="pc-filter-grid">
|
<el-form label-position="top" class="pc-filter-grid">
|
||||||
<el-form-item label="关键词">
|
<el-form-item label="关键词">
|
||||||
@@ -172,12 +160,12 @@ function listingPrice(item: Listing) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<el-empty
|
<el-empty
|
||||||
v-if="!loading && filteredListings.length === 0"
|
v-if="!loading && listings.length === 0"
|
||||||
description="暂无符合条件的租号"
|
description="暂无符合条件的租号"
|
||||||
/>
|
/>
|
||||||
<div v-else v-loading="loading" class="pc-resource-list">
|
<div v-else v-loading="loading" class="pc-resource-list">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="item in filteredListings"
|
v-for="item in listings"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="resource-card"
|
class="resource-card"
|
||||||
:to="`/listings/${item.id}`"
|
:to="`/listings/${item.id}`"
|
||||||
@@ -193,17 +181,17 @@ function listingPrice(item: Listing) {
|
|||||||
<div class="resource-main">
|
<div class="resource-main">
|
||||||
<div class="resource-title-line">
|
<div class="resource-title-line">
|
||||||
<strong>{{ item.title }}</strong>
|
<strong>{{ item.title }}</strong>
|
||||||
<em>{{ item.status === "published" ? "可租" : listingStatusLabel(item.status) }}</em>
|
<em>{{ item.status === 'published' ? '可租' : listingStatusLabel(item.status) }}</em>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
{{
|
{{
|
||||||
item.description || "号主暂未填写说明,可下单后按平台流程交接。"
|
item.description || '号主暂未填写说明,可下单后按平台流程交接。'
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
<div class="resource-tags">
|
<div class="resource-tags">
|
||||||
<span>{{ item.server_region }}</span>
|
<span>{{ item.server_region }}</span>
|
||||||
<span>{{ item.login_platform }}</span>
|
<span>{{ item.login_platform }}</span>
|
||||||
<span>{{ item.rank_level || "未填写段位" }}</span>
|
<span>{{ item.rank_level || '未填写段位' }}</span>
|
||||||
<span>哈夫币 {{ item.haf_coin_amount }}</span>
|
<span>哈夫币 {{ item.haf_coin_amount }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -214,5 +202,17 @@ function listingPrice(item: Listing) {
|
|||||||
</div>
|
</div>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination-wrap" v-if="total > 0">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="currentPageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@current-change="loadListings"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,9 +1,126 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, computed } from 'vue'
|
||||||
|
|
||||||
|
import { fetchOrders, type OrderListItem } from '@/api/orders'
|
||||||
|
import { orderStatusLabel, handoffStatusLabel } from '@/utils/statusLabels'
|
||||||
|
import { formatDateTime } from '@/utils/time'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const orders = ref<OrderListItem[]>([])
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const currentPageSize = ref(20)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
/** 只展示交接相关状态:待交接、待结账确认、待租客确认修正 */
|
||||||
|
const handoffStatuses = [
|
||||||
|
'pending_handoff',
|
||||||
|
'pending_checkout_confirm',
|
||||||
|
'pending_checkout_accept',
|
||||||
|
]
|
||||||
|
|
||||||
|
const activeTab = ref('all')
|
||||||
|
|
||||||
|
onMounted(loadOrders)
|
||||||
|
|
||||||
|
async function loadOrders() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await fetchOrders(currentPage.value, currentPageSize.value)
|
||||||
|
orders.value = result.items
|
||||||
|
total.value = result.total
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSizeChange() {
|
||||||
|
currentPage.value = 1
|
||||||
|
loadOrders()
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayOrders = computed(() => {
|
||||||
|
if (activeTab.value === 'all') {
|
||||||
|
return orders.value.filter((o) => handoffStatuses.includes(o.status))
|
||||||
|
}
|
||||||
|
return orders.value.filter((o) => o.status === activeTab.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handoffCount = computed(() => orders.value.filter((o) => o.status === 'pending_handoff').length)
|
||||||
|
const checkoutCount = computed(() =>
|
||||||
|
orders.value.filter((o) => o.status === 'pending_checkout_confirm' || o.status === 'pending_checkout_accept').length
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="page">
|
<section class="page">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<p class="eyebrow">Handoffs</p>
|
<p class="eyebrow">Handoffs</p>
|
||||||
<h1>交接管理</h1>
|
<h1>交接管理</h1>
|
||||||
<p>处理待交接、待归还确认和超时订单。</p>
|
<p>处理待交接、待结账确认和超时订单。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 状态快捷筛选 -->
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-radio-group v-model="activeTab" size="small">
|
||||||
|
<el-radio-button value="all">全部 ({{ handoffCount + checkoutCount }})</el-radio-button>
|
||||||
|
<el-radio-button value="pending_handoff">待交接 ({{ handoffCount }})</el-radio-button>
|
||||||
|
<el-radio-button value="pending_checkout_confirm">待结账 ({{ checkoutCount }})</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" class="table-panel" :data="displayOrders">
|
||||||
|
<el-table-column prop="order_no" label="订单号" min-width="210" />
|
||||||
|
<el-table-column prop="listing_title" label="商品名" min-width="180" />
|
||||||
|
<el-table-column label="租客/号主" min-width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span>{{ row.renter_nickname }}</span>
|
||||||
|
<span class="table-subtext">/ {{ row.owner_nickname }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="订单状态" width="130">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small">{{ orderStatusLabel(row.status) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="交接状态" width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" type="warning">{{ handoffStatusLabel(row.handoff_status) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="金额" min-width="130">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span>¥{{ row.rent_amount }}</span>
|
||||||
|
<span class="table-subtext">押金 ¥{{ row.deposit_amount }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="创建时间" min-width="180">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<RouterLink :to="`/orders/${row.id}`">
|
||||||
|
<el-button size="small">详情</el-button>
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pagination-wrap" v-if="total > 0">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="currentPageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@current-change="loadOrders"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-bar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -7,18 +7,28 @@ import { listingReviewStatusLabel, listingStatusLabel } from '@/utils/statusLabe
|
|||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const listings = ref<Listing[]>([])
|
const listings = ref<Listing[]>([])
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const currentPageSize = ref(20)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
onMounted(loadListings)
|
onMounted(loadListings)
|
||||||
|
|
||||||
async function loadListings() {
|
async function loadListings() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
listings.value = await fetchSellerListings()
|
const result = await fetchSellerListings(currentPage.value, currentPageSize.value)
|
||||||
|
listings.value = result.items
|
||||||
|
total.value = result.total
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSizeChange() {
|
||||||
|
currentPage.value = 1
|
||||||
|
loadListings()
|
||||||
|
}
|
||||||
|
|
||||||
async function submitReview(id: number) {
|
async function submitReview(id: number) {
|
||||||
const listing = await submitListingReview(id)
|
const listing = await submitListingReview(id)
|
||||||
ElMessage.success(
|
ElMessage.success(
|
||||||
@@ -65,7 +75,6 @@ function readSellerPrice(row: Listing) {
|
|||||||
<el-table v-loading="loading" class="table-panel" :data="listings">
|
<el-table v-loading="loading" class="table-panel" :data="listings">
|
||||||
<el-table-column prop="title" label="标题" min-width="180" />
|
<el-table-column prop="title" label="标题" min-width="180" />
|
||||||
<el-table-column prop="server_region" label="区服" width="120" />
|
<el-table-column prop="server_region" label="区服" width="120" />
|
||||||
<el-table-column prop="haf_coin_amount" label="哈夫币" width="120" />
|
|
||||||
<el-table-column label="价格" width="100">
|
<el-table-column label="价格" width="100">
|
||||||
<template #default="{ row }">{{ listingPrice(row) }}</template>
|
<template #default="{ row }">{{ listingPrice(row) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -90,5 +99,17 @@ function readSellerPrice(row: Listing) {
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pagination-wrap" v-if="total > 0">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="currentPageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@current-change="loadListings"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ init_database() {
|
|||||||
log "数据库结构已存在,跳过迁移"
|
log "数据库结构已存在,跳过迁移"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
log "应用增量迁移..."
|
||||||
|
docker exec -i hfb-mysql mysql -uhfb -psecret hfb_sys < "${ROOT_DIR}/backend/migrations/000002_order_checkouts.sql"
|
||||||
}
|
}
|
||||||
|
|
||||||
load_env_file() {
|
load_env_file() {
|
||||||
|
|||||||
Reference in New Issue
Block a user