修复鉴权:401拦截器加refresh重试 + admin refresh接口 + 路由守卫完善
根因:access_token每2小时过期,前端收到401直接清token跳登录,没有用refresh_token续期 后端修复: - adminauth模块新增 POST /admin/auth/refresh 接口 - Service 注入 JWTManager,支持 admin refresh token 换新 token pair - Refresh 方法验证 subjectType=admin + tokenType=refresh 前端修复: - 401 拦截器核心改造:收到401先调 refresh 接口续期 - 加 isRefreshing 锁 + pendingRequests 队列防止并发刷新 - refresh 用原生 axios.post 避免拦截器递归 - 成功则更新 localStorage + 重试原请求,失败才清 token 跳登录 - 排除 /auth/refresh 自身避免死循环 - 支持 /admin/ 请求独立 token 管理 - auth.ts/adminAuth.ts 新增手动 refreshUserToken/refreshAdminSession - 路由守卫给所有需登录路由添加 meta.requiresAuth - 守卫同时支持 PC 端 /login 和移动端 /m/login
This commit is contained in:
@@ -10,7 +10,15 @@ type Query struct {
|
||||
ActorID uint64
|
||||
Action 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 {
|
||||
|
||||
@@ -18,17 +18,32 @@ func NewHandler(service *Service) *Handler {
|
||||
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) {
|
||||
query, ok := parseQuery(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := h.service.List(query)
|
||||
result, err := h.service.List(query)
|
||||
if err != nil {
|
||||
writeAuditError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func parseQuery(c *gin.Context) (Query, bool) {
|
||||
@@ -41,16 +56,11 @@ func parseQuery(c *gin.Context) (Query, bool) {
|
||||
}
|
||||
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.BizType = c.Query("biz_type")
|
||||
page, pageSize := parsePagination(c)
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
return query, true
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ package adminaudit
|
||||
import (
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -15,37 +17,42 @@ func NewRepository(db *gorm.DB) *Repository {
|
||||
return &Repository{db: db}
|
||||
}
|
||||
|
||||
func (r *Repository) List(query Query) ([]LogDTO, error) {
|
||||
limit := query.Limit
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 200
|
||||
}
|
||||
|
||||
func (r *Repository) List(query Query) (*PaginatedResult, error) {
|
||||
db := r.db.Table("audit_logs AS al").
|
||||
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,
|
||||
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")
|
||||
|
||||
countDB := r.db.Model(&model.AuditLog{})
|
||||
if query.ActorID > 0 {
|
||||
db = db.Where("al.actor_id = ?", query.ActorID)
|
||||
countDB = countDB.Where("actor_id = ?", query.ActorID)
|
||||
}
|
||||
if query.Action != "" {
|
||||
db = db.Where("al.action = ?", query.Action)
|
||||
countDB = countDB.Where("action = ?", query.Action)
|
||||
}
|
||||
if 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
|
||||
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
|
||||
}
|
||||
items := make([]LogDTO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, row.toDTO())
|
||||
}
|
||||
return items, nil
|
||||
return &PaginatedResult{Items: items, Total: total, Page: query.Page, PageSize: query.PageSize}, nil
|
||||
}
|
||||
|
||||
type auditLogRow struct {
|
||||
|
||||
@@ -12,7 +12,7 @@ func NewService(repo *Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) List(query Query) ([]LogDTO, error) {
|
||||
func (s *Service) List(query Query) (*PaginatedResult, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
|
||||
@@ -65,10 +65,30 @@ func (h *Handler) Logout(c *gin.Context) {
|
||||
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) {
|
||||
switch {
|
||||
case errors.Is(err, ErrDependencyUnavailable):
|
||||
response.ServiceUnavailable(c, "数据库未连接")
|
||||
case errors.Is(err, ErrInvalidRefreshToken):
|
||||
response.Unauthorized(c, "刷新令牌无效或已过期")
|
||||
case errors.Is(err, ErrInvalidCredential):
|
||||
response.BadRequest(c, "用户名或密码错误")
|
||||
case errors.Is(err, ErrCaptchaInvalid):
|
||||
|
||||
@@ -1,20 +1,40 @@
|
||||
package adminauth
|
||||
|
||||
import "errors"
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"hfb_sys/backend/internal/modules/auth"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
||||
ErrInvalidCredential = errors.New("invalid credential")
|
||||
ErrCaptchaInvalid = errors.New("captcha invalid")
|
||||
ErrAdminDisabled = errors.New("admin disabled")
|
||||
ErrInvalidRefreshToken = errors.New("invalid refresh token")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo *Repository
|
||||
jwt *auth.JWTManager
|
||||
}
|
||||
func NewService(repo *Repository, jwt *auth.JWTManager) *Service {
|
||||
return &Service{repo: repo, jwt: jwt}
|
||||
}
|
||||
|
||||
func NewService(repo *Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
func (s *Service) Refresh(refreshToken string) (*auth.TokenPair, error) {
|
||||
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) {
|
||||
|
||||
@@ -17,7 +17,13 @@ type UserDTO struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type FreezeRequest struct {
|
||||
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}
|
||||
}
|
||||
|
||||
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) {
|
||||
items, err := h.service.List()
|
||||
page, pageSize := parsePagination(c)
|
||||
result, err := h.service.List(page, pageSize)
|
||||
if err != nil {
|
||||
writeAdminUserError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *Handler) Freeze(c *gin.Context) {
|
||||
|
||||
@@ -24,7 +24,12 @@ func NewRepository(db *gorm.DB) *Repository {
|
||||
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
|
||||
err := r.db.Table("users AS 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 user_id, COUNT(*) AS dispute_count FROM (SELECT initiator_id AS user_id FROM disputes UNION ALL SELECT target_user_id AS user_id FROM disputes) AS dispute_users GROUP BY user_id) AS d ON d.user_id = u.id").
|
||||
Order("u.id DESC").
|
||||
Limit(200).
|
||||
Offset(offset).Limit(pageSize).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -44,7 +49,7 @@ func (r *Repository) List() ([]UserDTO, error) {
|
||||
for _, row := range rows {
|
||||
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) {
|
||||
|
||||
@@ -15,11 +15,11 @@ func NewService(repo *Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) List() ([]UserDTO, error) {
|
||||
func (s *Service) List(page, pageSize int) (*PaginatedResult, error) {
|
||||
if s.repo == nil {
|
||||
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) {
|
||||
|
||||
@@ -36,8 +36,14 @@ type ArbitrateRequest struct {
|
||||
Remark string `json:"remark" binding:"required"`
|
||||
Amount float64 `json:"amount"`
|
||||
}
|
||||
|
||||
type AuditMeta struct {
|
||||
IP 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, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
items, err := h.service.ListForUser(userID)
|
||||
page, pageSize := parsePagination(c)
|
||||
result, err := h.service.ListForUser(userID, page, pageSize)
|
||||
if err != nil {
|
||||
writeDisputeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
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) {
|
||||
items, err := h.service.ListAdmin()
|
||||
page, pageSize := parsePagination(c)
|
||||
result, err := h.service.ListAdmin(page, pageSize)
|
||||
if err != nil {
|
||||
writeDisputeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *Handler) AdminArbitrate(c *gin.Context) {
|
||||
@@ -136,6 +138,21 @@ func parseID(c *gin.Context) (uint64, bool) {
|
||||
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) {
|
||||
switch {
|
||||
case errors.Is(err, ErrDependencyUnavailable):
|
||||
|
||||
@@ -125,16 +125,23 @@ func (r *Repository) Create(userID uint64, orderID uint64, req CreateRequest) (*
|
||||
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
|
||||
err := r.baseQuery().
|
||||
Where("d.initiator_id = ? OR d.target_user_id = ?", userID, userID).
|
||||
Order("d.id DESC").
|
||||
Offset(offset).Limit(pageSize).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
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) {
|
||||
@@ -148,13 +155,18 @@ func (r *Repository) FindForUser(userID uint64, id uint64) (*DisputeDTO, error)
|
||||
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
|
||||
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 {
|
||||
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) {
|
||||
|
||||
@@ -28,11 +28,11 @@ func (s *Service) Create(userID uint64, orderID uint64, req CreateRequest) (*Dis
|
||||
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 {
|
||||
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) {
|
||||
@@ -42,11 +42,11 @@ func (s *Service) FindForUser(userID uint64, id uint64) (*DisputeDTO, error) {
|
||||
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 {
|
||||
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) {
|
||||
|
||||
@@ -56,12 +56,19 @@ type AdminListQuery struct {
|
||||
OwnerID uint64
|
||||
Status string
|
||||
ReviewStatus string
|
||||
Limit int
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type AdminActionRequest struct {
|
||||
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 {
|
||||
IP string
|
||||
|
||||
@@ -82,12 +82,13 @@ func (h *Handler) SubmitReview(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 {
|
||||
writeListingError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *Handler) ListAdmin(c *gin.Context) {
|
||||
@@ -95,12 +96,12 @@ func (h *Handler) ListAdmin(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := h.service.ListAdmin(query)
|
||||
result, err := h.service.ListAdmin(query)
|
||||
if err != nil {
|
||||
writeListingError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
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) {
|
||||
items, err := h.service.ListPublic()
|
||||
page, pageSize := parsePagination(c)
|
||||
result, err := h.service.ListPublic(page, pageSize)
|
||||
if err != nil {
|
||||
writeListingError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *Handler) FindPublic(c *gin.Context) {
|
||||
@@ -276,12 +278,13 @@ func (h *Handler) ListMine(c *gin.Context) {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
items, err := h.service.ListMine(ownerID)
|
||||
page, pageSize := parsePagination(c)
|
||||
result, err := h.service.ListMine(ownerID, page, pageSize)
|
||||
if err != nil {
|
||||
writeListingError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *Handler) FindMine(c *gin.Context) {
|
||||
@@ -339,19 +342,27 @@ func parseAdminListQuery(c *gin.Context) (AdminListQuery, bool) {
|
||||
}
|
||||
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.ReviewStatus = c.Query("review_status")
|
||||
query.Page, query.PageSize = parsePagination(c)
|
||||
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) {
|
||||
switch {
|
||||
case errors.Is(err, ErrDependencyUnavailable):
|
||||
|
||||
@@ -158,26 +158,41 @@ func (r *Repository) SubmitReview(ownerID uint64, listingID uint64, reviewRequir
|
||||
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
|
||||
err := r.baseQuery().
|
||||
err := r.listQuery().
|
||||
Where("l.review_status = ?", "pending").
|
||||
Order("l.updated_at ASC, l.id ASC").
|
||||
Limit(200).
|
||||
Offset(offset).Limit(pageSize).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
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) {
|
||||
limit := query.Limit
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 200
|
||||
func (r *Repository) ListAdmin(query AdminListQuery) (*PaginatedResult, error) {
|
||||
conditions := r.db.Model(&model.RentalListing{})
|
||||
if query.OwnerID > 0 {
|
||||
conditions = conditions.Where("owner_id = ?", query.OwnerID)
|
||||
}
|
||||
|
||||
db := r.baseQuery()
|
||||
if query.Status != "" {
|
||||
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 {
|
||||
db = db.Where("l.owner_id = ?", query.OwnerID)
|
||||
}
|
||||
@@ -187,13 +202,13 @@ func (r *Repository) ListAdmin(query AdminListQuery) ([]ListingDTO, error) {
|
||||
if query.ReviewStatus != "" {
|
||||
db = db.Where("l.review_status = ?", query.ReviewStatus)
|
||||
}
|
||||
|
||||
offset := (query.Page - 1) * query.PageSize
|
||||
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 {
|
||||
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) {
|
||||
@@ -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
|
||||
err := r.baseQuery().
|
||||
err := r.listQuery().
|
||||
Where("l.status = ? AND l.review_status = ?", "published", "approved").
|
||||
Order("l.published_at DESC, l.id DESC").
|
||||
Limit(100).
|
||||
Offset(offset).Limit(pageSize).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
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
|
||||
err := r.baseQuery().
|
||||
err := r.listQuery().
|
||||
Where("l.owner_id = ?", ownerID).
|
||||
Order("l.id DESC").
|
||||
Offset(offset).Limit(pageSize).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
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) {
|
||||
@@ -449,6 +478,17 @@ func (r *Repository) baseQuery() *gorm.DB {
|
||||
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) {
|
||||
var listing model.RentalListing
|
||||
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)
|
||||
}
|
||||
|
||||
func (s *Service) ListPendingReview() ([]ListingDTO, error) {
|
||||
func (s *Service) ListPendingReview(page, pageSize int) (*PaginatedResult, error) {
|
||||
if s.repo == nil {
|
||||
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 {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
@@ -158,18 +158,18 @@ func (s *Service) Offline(ownerID uint64, id uint64) error {
|
||||
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 {
|
||||
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 {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
return s.repo.ListMine(ownerID)
|
||||
return s.repo.ListMine(ownerID, page, pageSize)
|
||||
}
|
||||
|
||||
func (s *Service) FindPublic(id uint64) (*ListingDTO, error) {
|
||||
|
||||
@@ -13,3 +13,10 @@ type NotificationDTO struct {
|
||||
ReadAt *time.Time `json:"read_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}
|
||||
}
|
||||
|
||||
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) {
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
items, err := h.service.List(userID)
|
||||
page, pageSize := parsePagination(c)
|
||||
result, err := h.service.List(userID, page, pageSize)
|
||||
if err != nil {
|
||||
writeNotificationError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *Handler) MarkRead(c *gin.Context) {
|
||||
|
||||
@@ -25,16 +25,21 @@ func NewRepository(db *gorm.DB) *Repository {
|
||||
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
|
||||
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
|
||||
}
|
||||
items := make([]NotificationDTO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
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 {
|
||||
|
||||
@@ -12,11 +12,11 @@ func NewService(repo *Repository) *Service {
|
||||
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 {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
return s.repo.List(userID)
|
||||
return s.repo.List(userID, page, pageSize)
|
||||
}
|
||||
|
||||
func (s *Service) MarkRead(userID uint64, id uint64) error {
|
||||
|
||||
@@ -78,6 +78,29 @@ type HandoffRecordDTO struct {
|
||||
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"`
|
||||
|
||||
@@ -44,21 +44,43 @@ func (h *Handler) List(c *gin.Context) {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
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 {
|
||||
writeOrderError(c, err)
|
||||
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) {
|
||||
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 {
|
||||
writeOrderError(c, err)
|
||||
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) {
|
||||
|
||||
@@ -488,36 +488,45 @@ func (r *Repository) AcceptCheckout(userID uint64, orderID uint64) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Repository) ListForUser(userID uint64) ([]OrderDTO, error) {
|
||||
var rows []orderRow
|
||||
err := r.baseQuery().
|
||||
Where("o.renter_id = ?", userID).
|
||||
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").
|
||||
Offset(offset).Limit(pageSize).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]OrderDTO, 0, len(rows))
|
||||
items := make([]OrderListItemDTO, 0, len(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() ([]OrderDTO, error) {
|
||||
var rows []orderRow
|
||||
err := r.adminQuery().
|
||||
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").
|
||||
Limit(200).
|
||||
Offset(offset).Limit(pageSize).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]OrderDTO, 0, len(rows))
|
||||
items := make([]OrderListItemDTO, 0, len(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) FindAdmin(orderID uint64) (*OrderDTO, error) {
|
||||
@@ -898,6 +907,16 @@ func (r *Repository) findHandoffRecord(id uint64) (*HandoffRecordDTO, error) {
|
||||
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 {
|
||||
return r.db.Table("rental_orders AS o").
|
||||
Select("o.*, a.title, a.server_region, a.login_platform").
|
||||
@@ -948,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 {
|
||||
return HandoffRecordDTO{
|
||||
ID: record.ID,
|
||||
|
||||
@@ -103,18 +103,18 @@ func (s *Service) AcceptCheckout(userID uint64, orderID uint64) error {
|
||||
return s.repo.AcceptCheckout(userID, orderID)
|
||||
}
|
||||
|
||||
func (s *Service) ListForUser(userID uint64) ([]OrderDTO, error) {
|
||||
func (s *Service) ListForUser(userID uint64, page int, pageSize int) (*PaginatedOrders, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
return s.repo.ListForUser(userID)
|
||||
return s.repo.ListForUser(userID, page, pageSize)
|
||||
}
|
||||
|
||||
func (s *Service) ListAdmin() ([]OrderDTO, error) {
|
||||
func (s *Service) ListAdmin(page int, pageSize int) (*PaginatedOrders, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
return s.repo.ListAdmin()
|
||||
return s.repo.ListAdmin(page, pageSize)
|
||||
}
|
||||
|
||||
func (s *Service) FindAdmin(orderID uint64) (*OrderDTO, error) {
|
||||
|
||||
@@ -28,7 +28,15 @@ type AdminLedgerQuery struct {
|
||||
UserID uint64
|
||||
OrderID uint64
|
||||
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 {
|
||||
|
||||
@@ -32,18 +32,34 @@ func (h *Handler) Balance(c *gin.Context) {
|
||||
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) {
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
items, err := h.service.Ledger(userID)
|
||||
page, pageSize := parsePagination(c)
|
||||
result, err := h.service.Ledger(userID, page, pageSize)
|
||||
if err != nil {
|
||||
writeWalletError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *Handler) AdminLedger(c *gin.Context) {
|
||||
@@ -51,12 +67,12 @@ func (h *Handler) AdminLedger(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := h.service.AdminLedger(query)
|
||||
result, err := h.service.AdminLedger(query)
|
||||
if err != nil {
|
||||
writeWalletError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func parseAdminLedgerQuery(c *gin.Context) (AdminLedgerQuery, bool) {
|
||||
@@ -77,15 +93,10 @@ func parseAdminLedgerQuery(c *gin.Context) (AdminLedgerQuery, bool) {
|
||||
}
|
||||
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")
|
||||
page, pageSize := parsePagination(c)
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
return query, true
|
||||
}
|
||||
|
||||
|
||||
@@ -45,24 +45,24 @@ func (r *Repository) Account(userID uint64) (*AccountDTO, error) {
|
||||
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
|
||||
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
|
||||
}
|
||||
items := make([]LedgerDTO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, toLedgerDTO(row))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *Repository) AdminLedger(query AdminLedgerQuery) ([]AdminLedgerDTO, error) {
|
||||
limit := query.Limit
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 200
|
||||
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) AdminLedger(query AdminLedgerQuery) (*PaginatedResult, error) {
|
||||
db := r.db.Table("wallet_ledger AS wl").
|
||||
Select(`wl.id, wl.ledger_no, wl.user_id, COALESCE(u.phone, '') AS user_phone,
|
||||
COALESCE(u.nickname, '') AS user_nickname, wl.order_id, COALESCE(ro.order_no, '') AS order_no,
|
||||
@@ -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 rental_orders AS ro ON ro.id = wl.order_id")
|
||||
|
||||
countDB := r.db.Model(&model.WalletLedger{})
|
||||
if query.UserID > 0 {
|
||||
db = db.Where("wl.user_id = ?", query.UserID)
|
||||
countDB = countDB.Where("user_id = ?", query.UserID)
|
||||
}
|
||||
if query.OrderID > 0 {
|
||||
db = db.Where("wl.order_id = ?", query.OrderID)
|
||||
countDB = countDB.Where("order_id = ?", query.OrderID)
|
||||
}
|
||||
if query.BizType != "" {
|
||||
db = db.Where("wl.biz_type = ?", query.BizType)
|
||||
countDB = countDB.Where("biz_type = ?", query.BizType)
|
||||
}
|
||||
|
||||
var items []AdminLedgerDTO
|
||||
if err := db.Order("wl.id DESC").Limit(limit).Scan(&items).Error; err != nil {
|
||||
var total int64
|
||||
if err := countDB.Count(&total).Error; err != nil {
|
||||
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 {
|
||||
|
||||
@@ -19,14 +19,14 @@ func (s *Service) Account(userID uint64) (*AccountDTO, error) {
|
||||
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 {
|
||||
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 {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
if deps.DB != nil {
|
||||
adminAuthRepo = adminauth.NewRepository(deps.DB, deps.Redis, jwtManager)
|
||||
}
|
||||
adminAuthService := adminauth.NewService(adminAuthRepo)
|
||||
adminAuthService := adminauth.NewService(adminAuthRepo, jwtManager)
|
||||
adminAuthHandler := adminauth.NewHandler(adminAuthService)
|
||||
var adminDashboardRepo *admindashboard.Repository
|
||||
if deps.DB != nil {
|
||||
@@ -211,6 +211,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
{
|
||||
adminAuthRoutes.GET("/captcha", adminAuthHandler.Captcha)
|
||||
adminAuthRoutes.POST("/login", adminAuthHandler.Login)
|
||||
adminAuthRoutes.POST("/refresh", adminAuthHandler.Refresh)
|
||||
}
|
||||
|
||||
adminRoutes := api.Group("/admin", requireAdmin)
|
||||
|
||||
@@ -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);
|
||||
@@ -1,5 +1,7 @@
|
||||
import { apiClient } from './client'
|
||||
|
||||
import { type PaginatedResult } from './orders'
|
||||
|
||||
export interface AdminAuditLog {
|
||||
id: number
|
||||
actor_type: string
|
||||
@@ -19,7 +21,8 @@ export interface AdminAuditQuery {
|
||||
actor_id?: string
|
||||
action?: string
|
||||
biz_type?: string
|
||||
limit?: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
@@ -30,6 +33,6 @@ interface ApiResponse<T> {
|
||||
|
||||
export async function fetchAdminAuditLogs(query: AdminAuditQuery = {}) {
|
||||
const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined))
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: AdminAuditLog[] }>>('/admin/audit-logs', { params })
|
||||
return data.data.items
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminAuditLog>>>('/admin/audit-logs', { params })
|
||||
return data.data
|
||||
}
|
||||
|
||||
@@ -56,3 +56,12 @@ export async function logoutAdmin() {
|
||||
const { data } = await apiClient.post<ApiResponse<{ logged_out: boolean }>>('/admin/auth/logout')
|
||||
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 { type PaginatedResult } from './orders'
|
||||
|
||||
export interface AdminUserItem {
|
||||
id: number
|
||||
phone: string
|
||||
@@ -22,9 +24,11 @@ interface ApiResponse<T> {
|
||||
data: T
|
||||
}
|
||||
|
||||
export async function fetchAdminUsers() {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: AdminUserItem[] }>>('/admin/users')
|
||||
return data.data.items
|
||||
export async function fetchAdminUsers(page = 1, pageSize = 20) {
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminUserItem>>>('/admin/users', {
|
||||
params: { page, page_size: pageSize },
|
||||
})
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function freezeAdminUser(id: number, reason: string) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { apiClient } from './client'
|
||||
|
||||
import { type PaginatedResult } from './orders'
|
||||
|
||||
export interface AdminWalletLedger {
|
||||
id: number
|
||||
ledger_no: string
|
||||
@@ -22,7 +24,8 @@ export interface AdminWalletLedgerQuery {
|
||||
user_id?: string
|
||||
order_id?: string
|
||||
biz_type?: string
|
||||
limit?: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
@@ -33,6 +36,6 @@ interface ApiResponse<T> {
|
||||
|
||||
export async function fetchAdminWalletLedger(query: AdminWalletLedgerQuery = {}) {
|
||||
const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined))
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: AdminWalletLedger[] }>>('/admin/wallet/ledger', { params })
|
||||
return data.data.items
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminWalletLedger>>>('/admin/wallet/ledger', { params })
|
||||
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)
|
||||
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
|
||||
}
|
||||
+99
-22
@@ -1,10 +1,53 @@
|
||||
import axios from 'axios'
|
||||
import axios, { type InternalAxiosRequestConfig } from 'axios'
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: '/api',
|
||||
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) => {
|
||||
const url = config.url || ''
|
||||
const tokenKey = url.startsWith('/admin') ? 'admin_access_token' : 'access_token'
|
||||
@@ -15,40 +58,74 @@ apiClient.interceptors.request.use((config) => {
|
||||
return config
|
||||
})
|
||||
|
||||
// ---- Response interceptor ----
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error?.response?.status !== 401) {
|
||||
async (error) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||||
|
||||
if (error?.response?.status !== 401 || originalRequest._retry) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
const requestUrl = error.config?.url || ''
|
||||
const requestUrl = originalRequest.url || ''
|
||||
const isAdminRequest = requestUrl.startsWith('/admin')
|
||||
const currentPath = window.location.pathname + window.location.search
|
||||
|
||||
// exclude refresh endpoints themselves to avoid dead loop
|
||||
if (requestUrl.endsWith('/auth/refresh') || requestUrl.endsWith('/admin/auth/refresh')) {
|
||||
if (isAdminRequest) clearAdminTokens()
|
||||
else clearUserTokens()
|
||||
if (isAdminRequest && !window.location.pathname.startsWith('/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)
|
||||
}
|
||||
|
||||
// if already refreshing, queue up
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve) => {
|
||||
addPendingRequest((newToken: string) => {
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
resolve(apiClient(originalRequest))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 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) {
|
||||
localStorage.removeItem('admin_access_token')
|
||||
localStorage.removeItem('admin_refresh_token')
|
||||
clearAdminTokens()
|
||||
if (!window.location.pathname.startsWith('/admin/login')) {
|
||||
window.location.assign('/admin/login')
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
localStorage.removeItem('user_id')
|
||||
localStorage.removeItem('phone')
|
||||
localStorage.removeItem('nickname')
|
||||
localStorage.removeItem('avatar_url')
|
||||
localStorage.removeItem('realname_status')
|
||||
|
||||
if (!window.location.pathname.startsWith('/m/login') && !window.location.pathname.startsWith('/m/register')) {
|
||||
} else {
|
||||
clearUserTokens()
|
||||
const currentPath = window.location.pathname + window.location.search
|
||||
if (!currentPath.startsWith('/m/login') && !currentPath.startsWith('/login')) {
|
||||
const redirect = encodeURIComponent(currentPath)
|
||||
const loginPath = window.location.pathname.startsWith('/m') ? '/m/login' : '/login'
|
||||
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 { type PaginatedResult } from './orders'
|
||||
|
||||
export interface Dispute {
|
||||
id: number
|
||||
order_id: number
|
||||
@@ -30,14 +32,18 @@ export async function createDispute(orderId: number, payload: { type: string; de
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchDisputes() {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: Dispute[] }>>('/disputes')
|
||||
return data.data.items
|
||||
export async function fetchDisputes(page = 1, pageSize = 20) {
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<Dispute>>>('/disputes', {
|
||||
params: { page, page_size: pageSize },
|
||||
})
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchAdminDisputes() {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: Dispute[] }>>('/admin/disputes')
|
||||
return data.data.items
|
||||
export async function fetchAdminDisputes(page = 1, pageSize = 20) {
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<Dispute>>>('/admin/disputes', {
|
||||
params: { page, page_size: pageSize },
|
||||
})
|
||||
return data.data
|
||||
}
|
||||
|
||||
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 {
|
||||
id: number
|
||||
account_id: number
|
||||
owner_id: number
|
||||
owner_phone?: string
|
||||
owner_nickname?: string
|
||||
title: string
|
||||
description: string
|
||||
game_name: string
|
||||
server_region: string
|
||||
login_platform: string
|
||||
rank_level: string
|
||||
haf_coin_amount: number
|
||||
asset_summary?: Record<string, unknown>
|
||||
screenshot_urls: string[]
|
||||
cover_url: string
|
||||
price_hourly: number
|
||||
price_daily: number
|
||||
price_weekly: number
|
||||
deposit_amount: number
|
||||
is_accelerated_sale?: boolean
|
||||
status: string
|
||||
review_status: string
|
||||
review_reason: string
|
||||
published_at?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
id: number;
|
||||
account_id: number;
|
||||
owner_id: number;
|
||||
owner_phone?: string;
|
||||
owner_nickname?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
game_name: string;
|
||||
server_region: string;
|
||||
login_platform: string;
|
||||
rank_level: string;
|
||||
haf_coin_amount: number;
|
||||
asset_summary?: Record<string, unknown>;
|
||||
screenshot_urls: string[];
|
||||
cover_url: string;
|
||||
price_hourly: number;
|
||||
price_daily: number;
|
||||
price_weekly: number;
|
||||
deposit_amount: number;
|
||||
is_accelerated_sale?: boolean;
|
||||
status: string;
|
||||
review_status: string;
|
||||
review_reason: string;
|
||||
published_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ListingPayload {
|
||||
title: string
|
||||
description: string
|
||||
server_region: string
|
||||
login_platform: string
|
||||
rank_level: string
|
||||
haf_coin_amount: number
|
||||
asset_summary?: Record<string, unknown>
|
||||
screenshot_urls: string[]
|
||||
price_hourly: number
|
||||
price_daily: number
|
||||
price_weekly: number
|
||||
deposit_amount: number
|
||||
title: string;
|
||||
description: string;
|
||||
server_region: string;
|
||||
login_platform: string;
|
||||
rank_level: string;
|
||||
haf_coin_amount: number;
|
||||
asset_summary?: Record<string, unknown>;
|
||||
screenshot_urls: string[];
|
||||
price_hourly: number;
|
||||
price_daily: number;
|
||||
price_weekly: number;
|
||||
deposit_amount: number;
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
code: string
|
||||
message: string
|
||||
data: T
|
||||
code: string;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export async function fetchListings() {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: Listing[] }>>('/listings')
|
||||
return data.data.items
|
||||
export async function fetchListings(page = 1, pageSize = 20) {
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<Listing>>>(
|
||||
"/listings",
|
||||
{
|
||||
params: { page, page_size: pageSize },
|
||||
}
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function fetchListing(id: string | number) {
|
||||
const { data } = await apiClient.get<ApiResponse<Listing>>(`/listings/${id}`)
|
||||
return data.data
|
||||
const { data } = await apiClient.get<ApiResponse<Listing>>(`/listings/${id}`);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function fetchSellerListings() {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: Listing[] }>>('/seller/listings')
|
||||
return data.data.items
|
||||
export async function fetchSellerListings(page = 1, pageSize = 20) {
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<Listing>>>(
|
||||
"/seller/listings",
|
||||
{
|
||||
params: { page, page_size: pageSize },
|
||||
}
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createListing(payload: ListingPayload) {
|
||||
const { data } = await apiClient.post<ApiResponse<Listing>>('/listings', payload)
|
||||
return data.data
|
||||
const { data } = await apiClient.post<ApiResponse<Listing>>(
|
||||
"/listings",
|
||||
payload
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function submitListingReview(id: number) {
|
||||
const { data } = await apiClient.post<ApiResponse<Listing>>(`/listings/${id}/submit-review`)
|
||||
return data.data
|
||||
const { data } = await apiClient.post<ApiResponse<Listing>>(
|
||||
`/listings/${id}/submit-review`
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function offlineListing(id: number) {
|
||||
const { data } = await apiClient.delete<ApiResponse<{ offline: boolean }>>(`/listings/${id}`)
|
||||
return data.data
|
||||
const { data } = await apiClient.delete<ApiResponse<{ offline: boolean }>>(
|
||||
`/listings/${id}`
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function fetchPendingReviewListings() {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: Listing[] }>>('/admin/listings/pending')
|
||||
return data.data.items
|
||||
export async function fetchPendingReviewListings(page = 1, pageSize = 20) {
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<Listing>>>(
|
||||
"/admin/listings/pending",
|
||||
{
|
||||
params: { page, page_size: pageSize },
|
||||
}
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export interface AdminListingQuery {
|
||||
owner_id?: string
|
||||
status?: string
|
||||
review_status?: string
|
||||
limit?: number
|
||||
owner_id?: string;
|
||||
status?: string;
|
||||
review_status?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
export async function fetchAdminListings(query: AdminListingQuery = {}) {
|
||||
const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined))
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: Listing[] }>>('/admin/listings', { params })
|
||||
return data.data.items
|
||||
const params = Object.fromEntries(
|
||||
Object.entries(query).filter(
|
||||
([, 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) {
|
||||
const { data } = await apiClient.get<ApiResponse<Listing>>(`/admin/listings/${id}`)
|
||||
return data.data
|
||||
const { data } = await apiClient.get<ApiResponse<Listing>>(
|
||||
`/admin/listings/${id}`
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function adminOfflineListing(id: number, reason: string) {
|
||||
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/offline`, { reason })
|
||||
return data.data
|
||||
const { data } = await apiClient.post<ApiResponse<Listing>>(
|
||||
`/admin/listings/${id}/offline`,
|
||||
{ reason }
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function adminMarkListingAbnormal(id: number, reason: string) {
|
||||
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/mark-abnormal`, { reason })
|
||||
return data.data
|
||||
const { data } = await apiClient.post<ApiResponse<Listing>>(
|
||||
`/admin/listings/${id}/mark-abnormal`,
|
||||
{ reason }
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function approveListing(id: number) {
|
||||
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/approve`)
|
||||
return data.data
|
||||
const { data } = await apiClient.post<ApiResponse<Listing>>(
|
||||
`/admin/listings/${id}/approve`
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function rejectListing(id: number, reason: string) {
|
||||
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/reject`, { reason })
|
||||
return data.data
|
||||
const { data } = await apiClient.post<ApiResponse<Listing>>(
|
||||
`/admin/listings/${id}/reject`,
|
||||
{ reason }
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { apiClient } from './client'
|
||||
|
||||
import { type PaginatedResult } from './orders'
|
||||
|
||||
export interface NotificationItem {
|
||||
id: number
|
||||
user_id: number
|
||||
@@ -18,9 +20,11 @@ interface ApiResponse<T> {
|
||||
data: T
|
||||
}
|
||||
|
||||
export async function fetchNotifications() {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: NotificationItem[] }>>('/notifications')
|
||||
return data.data.items
|
||||
export async function fetchNotifications(page = 1, pageSize = 20) {
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<NotificationItem>>>('/notifications', {
|
||||
params: { page, page_size: pageSize },
|
||||
})
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function markNotificationRead(id: number) {
|
||||
|
||||
+170
-104
@@ -1,167 +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 {
|
||||
id: number
|
||||
order_no: string
|
||||
listing_id: number
|
||||
account_id: number
|
||||
owner_id: number
|
||||
renter_id: number
|
||||
owner_phone?: string
|
||||
renter_phone?: string
|
||||
title: string
|
||||
server_region: string
|
||||
login_platform: string
|
||||
rent_start_at?: string
|
||||
rent_end_at?: string
|
||||
rent_amount: number
|
||||
deposit_amount: number
|
||||
platform_fee: number
|
||||
account_snapshot?: Record<string, unknown>
|
||||
status: string
|
||||
handoff_status: string
|
||||
settlement_status: string
|
||||
checkout?: Checkout
|
||||
created_at: string
|
||||
updated_at: string
|
||||
id: number;
|
||||
order_no: string;
|
||||
listing_id: number;
|
||||
account_id: number;
|
||||
owner_id: number;
|
||||
renter_id: number;
|
||||
owner_phone?: string;
|
||||
renter_phone?: string;
|
||||
title: string;
|
||||
server_region: string;
|
||||
login_platform: string;
|
||||
rent_start_at?: string;
|
||||
rent_end_at?: string;
|
||||
rent_amount: number;
|
||||
deposit_amount: number;
|
||||
platform_fee: number;
|
||||
account_snapshot?: Record<string, unknown>;
|
||||
status: string;
|
||||
handoff_status: string;
|
||||
settlement_status: string;
|
||||
checkout?: Checkout;
|
||||
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
|
||||
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 {
|
||||
id: number
|
||||
order_id: number
|
||||
from_user_id: number
|
||||
to_user_id: number
|
||||
type: string
|
||||
content: string
|
||||
confirmed_by_renter_at?: string
|
||||
confirmed_by_owner_at?: string
|
||||
created_at: string
|
||||
id: number;
|
||||
order_id: number;
|
||||
from_user_id: number;
|
||||
to_user_id: number;
|
||||
type: string;
|
||||
content: string;
|
||||
confirmed_by_renter_at?: string;
|
||||
confirmed_by_owner_at?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SubmitCheckoutPayload {
|
||||
content: string
|
||||
consumable_amount: number
|
||||
coin_consumed_m: number
|
||||
other_amount: number
|
||||
evidence_urls: string[]
|
||||
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[]
|
||||
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> {
|
||||
code: string
|
||||
message: string
|
||||
data: T
|
||||
code: string;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
return data.data
|
||||
});
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function fetchOrders() {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: Order[] }>>('/orders')
|
||||
return data.data.items
|
||||
export async function fetchOrders(page = 1, pageSize = 20) {
|
||||
const { data } = await apiClient.get<
|
||||
ApiResponse<PaginatedResult<OrderListItem>>
|
||||
>("/orders", {
|
||||
params: { page, page_size: pageSize },
|
||||
});
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function fetchAdminOrders() {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: Order[] }>>('/admin/orders')
|
||||
return data.data.items
|
||||
export async function fetchAdminOrders(page = 1, pageSize = 20) {
|
||||
const { data } = await apiClient.get<
|
||||
ApiResponse<PaginatedResult<OrderListItem>>
|
||||
>("/admin/orders", {
|
||||
params: { page, page_size: pageSize },
|
||||
});
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function fetchOrder(id: string | number) {
|
||||
const { data } = await apiClient.get<ApiResponse<Order>>(`/orders/${id}`)
|
||||
return data.data
|
||||
const { data } = await apiClient.get<ApiResponse<Order>>(`/orders/${id}`);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function fetchAdminOrder(id: string | number) {
|
||||
const { data } = await apiClient.get<ApiResponse<Order>>(`/admin/orders/${id}`)
|
||||
return data.data
|
||||
const { data } = await apiClient.get<ApiResponse<Order>>(
|
||||
`/admin/orders/${id}`
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function cancelOrder(id: number) {
|
||||
const { data } = await apiClient.post<ApiResponse<{ cancelled: boolean }>>(`/orders/${id}/cancel`)
|
||||
return data.data
|
||||
const { data } = await apiClient.post<ApiResponse<{ cancelled: boolean }>>(
|
||||
`/orders/${id}/cancel`
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function submitHandoff(id: number, content: string) {
|
||||
const { data } = await apiClient.post<ApiResponse<HandoffRecord>>(`/orders/${id}/handoff`, { content })
|
||||
return data.data
|
||||
const { data } = await apiClient.post<ApiResponse<HandoffRecord>>(
|
||||
`/orders/${id}/handoff`,
|
||||
{ content }
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function fetchHandoffRecords(id: string | number) {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: HandoffRecord[] }>>(`/orders/${id}/handoff-records`)
|
||||
return data.data.items
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: HandoffRecord[] }>>(
|
||||
`/orders/${id}/handoff-records`
|
||||
);
|
||||
return data.data.items;
|
||||
}
|
||||
|
||||
export async function fetchAdminHandoffRecords(id: string | number) {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: HandoffRecord[] }>>(`/admin/orders/${id}/handoff-records`)
|
||||
return data.data.items
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: HandoffRecord[] }>>(
|
||||
`/admin/orders/${id}/handoff-records`
|
||||
);
|
||||
return data.data.items;
|
||||
}
|
||||
|
||||
export async function adminCloseOrder(id: number, reason: string) {
|
||||
const { data } = await apiClient.post<ApiResponse<{ closed: boolean }>>(`/admin/orders/${id}/close`, { reason })
|
||||
return data.data
|
||||
const { data } = await apiClient.post<ApiResponse<{ closed: boolean }>>(
|
||||
`/admin/orders/${id}/close`,
|
||||
{ reason }
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function adminMarkOrderAbnormal(id: number, reason: string) {
|
||||
const { data } = await apiClient.post<ApiResponse<{ abnormal: boolean }>>(`/admin/orders/${id}/mark-abnormal`, { reason })
|
||||
return data.data
|
||||
const { data } = await apiClient.post<ApiResponse<{ abnormal: boolean }>>(
|
||||
`/admin/orders/${id}/mark-abnormal`,
|
||||
{ reason }
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function confirmReceive(id: number) {
|
||||
const { data } = await apiClient.post<ApiResponse<{ received: boolean }>>(`/orders/${id}/confirm-receive`)
|
||||
return data.data
|
||||
const { data } = await apiClient.post<ApiResponse<{ received: boolean }>>(
|
||||
`/orders/${id}/confirm-receive`
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function submitCheckout(id: number, payload: SubmitCheckoutPayload) {
|
||||
const { data } = await apiClient.post<ApiResponse<HandoffRecord>>(`/orders/${id}/checkout`, payload)
|
||||
return data.data
|
||||
export async function submitCheckout(
|
||||
id: number,
|
||||
payload: SubmitCheckoutPayload
|
||||
) {
|
||||
const { data } = await apiClient.post<ApiResponse<HandoffRecord>>(
|
||||
`/orders/${id}/checkout`,
|
||||
payload
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function confirmCheckout(id: number) {
|
||||
const { data } = await apiClient.post<ApiResponse<{ completed: boolean }>>(`/orders/${id}/checkout/confirm`)
|
||||
return data.data
|
||||
const { data } = await apiClient.post<ApiResponse<{ completed: boolean }>>(
|
||||
`/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 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
|
||||
const { data } = await apiClient.post<ApiResponse<{ completed: boolean }>>(
|
||||
`/orders/${id}/checkout/accept`
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { apiClient } from './client'
|
||||
|
||||
import { type PaginatedResult } from './orders'
|
||||
|
||||
export interface WalletAccount {
|
||||
user_id: number
|
||||
available_balance: number
|
||||
@@ -33,7 +35,9 @@ export async function fetchWalletBalance() {
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchWalletLedger() {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: WalletLedger[] }>>('/wallet/ledger')
|
||||
return data.data.items
|
||||
export async function fetchWalletLedger(page = 1, pageSize = 20) {
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<WalletLedger>>>('/wallet/ledger', {
|
||||
params: { page, page_size: pageSize },
|
||||
})
|
||||
return data.data
|
||||
}
|
||||
|
||||
@@ -92,51 +92,61 @@ const router = createRouter({
|
||||
path: "/orders/create",
|
||||
name: "order-create",
|
||||
component: () => import("@/views/account/OrderCreateView.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/orders",
|
||||
name: "orders",
|
||||
component: () => import("@/views/account/OrdersView.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/orders/:id",
|
||||
name: "order-detail",
|
||||
component: () => import("@/views/account/OrderDetailView.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/wallet",
|
||||
name: "wallet",
|
||||
component: () => import("@/views/account/WalletView.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/notifications",
|
||||
name: "notifications",
|
||||
component: () => import("@/views/account/NotificationsView.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/realname",
|
||||
name: "realname",
|
||||
component: () => import("@/views/account/RealnameView.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/seller/listings",
|
||||
name: "seller-listings",
|
||||
component: () => import("@/views/seller/SellerListingsView.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/seller/listings/create",
|
||||
name: "seller-listing-create",
|
||||
component: () => import("@/views/seller/SellerListingCreateView.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/seller/handoffs",
|
||||
name: "seller-handoffs",
|
||||
component: () => import("@/views/seller/SellerHandoffsView.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/seller/earnings",
|
||||
name: "seller-earnings",
|
||||
component: () => import("@/views/seller/SellerEarningsView.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/admin/login",
|
||||
@@ -220,14 +230,17 @@ router.beforeEach(async (to) => {
|
||||
|
||||
if (to.meta.requiresAuth) {
|
||||
const session = useSessionStore();
|
||||
if (!session.token) {
|
||||
return { path: "/m/login", query: { redirect: to.fullPath } };
|
||||
const hasToken = !!localStorage.getItem("access_token");
|
||||
if (!hasToken) {
|
||||
const loginPath = to.path.startsWith("/m") ? "/m/login" : "/login";
|
||||
return { path: loginPath, query: { redirect: to.fullPath } };
|
||||
}
|
||||
if (!session.phone) {
|
||||
try {
|
||||
await session.loadMe();
|
||||
} 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 } };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1626,3 +1626,10 @@ h1 {
|
||||
background: var(--van-toast-background) !important;
|
||||
color: var(--van-toast-text-color) !important;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
@@ -7,18 +7,28 @@ import { formatDateTime } from '@/utils/time'
|
||||
|
||||
const loading = ref(false)
|
||||
const notifications = ref<NotificationItem[]>([])
|
||||
const currentPage = ref(1)
|
||||
const currentPageSize = ref(20)
|
||||
const total = ref(0)
|
||||
|
||||
onMounted(loadNotifications)
|
||||
|
||||
async function loadNotifications() {
|
||||
loading.value = true
|
||||
try {
|
||||
notifications.value = await fetchNotifications()
|
||||
const result = await fetchNotifications(currentPage.value, currentPageSize.value)
|
||||
notifications.value = result.items
|
||||
total.value = result.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
currentPage.value = 1
|
||||
loadNotifications()
|
||||
}
|
||||
|
||||
async function markRead(id: number) {
|
||||
await markNotificationRead(id)
|
||||
ElMessage.success('已标记为已读')
|
||||
@@ -51,5 +61,17 @@ async function markRead(id: number) {
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
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 { formatDateTime } from '@/utils/time'
|
||||
|
||||
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)
|
||||
|
||||
async function loadOrders() {
|
||||
loading.value = true
|
||||
try {
|
||||
orders.value = await fetchOrders()
|
||||
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()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -29,12 +40,19 @@ async function loadOrders() {
|
||||
|
||||
<el-table v-loading="loading" class="table-panel" :data="orders">
|
||||
<el-table-column prop="order_no" label="订单号" min-width="210" />
|
||||
<el-table-column prop="title" label="账号" min-width="180" />
|
||||
<el-table-column prop="rent_amount" label="订单金额" width="100" />
|
||||
<el-table-column prop="deposit_amount" label="押金" width="100" />
|
||||
<el-table-column prop="listing_title" label="商品名" min-width="180" />
|
||||
<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="状态" width="140">
|
||||
<template #default="{ row }">{{ orderStatusLabel(row.status) }}</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}`">
|
||||
@@ -43,5 +61,17 @@ async function loadOrders() {
|
||||
</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>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -8,19 +8,32 @@ import { formatDateTime } from '@/utils/time'
|
||||
const loading = ref(false)
|
||||
const account = ref<WalletAccount | null>(null)
|
||||
const ledger = ref<WalletLedger[]>([])
|
||||
const currentPage = ref(1)
|
||||
const currentPageSize = ref(20)
|
||||
const total = ref(0)
|
||||
|
||||
onMounted(loadWallet)
|
||||
|
||||
async function loadWallet() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [balance, rows] = await Promise.all([fetchWalletBalance(), fetchWalletLedger()])
|
||||
const [balance, result] = await Promise.all([fetchWalletBalance(), fetchWalletLedger(currentPage.value, currentPageSize.value)])
|
||||
account.value = balance
|
||||
ledger.value = rows
|
||||
ledger.value = result.items
|
||||
total.value = result.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
currentPage.value = 1
|
||||
loadWallet()
|
||||
}
|
||||
|
||||
function loadLedgerPage() {
|
||||
loadWallet()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -28,7 +41,7 @@ async function loadWallet() {
|
||||
<div class="page-header">
|
||||
<p class="eyebrow">Wallet</p>
|
||||
<h1>钱包</h1>
|
||||
<p>查看可用余额、冻结余额和不可变资金流水。当前是开发态模拟账务,不代表真实支付余额。</p>
|
||||
<p>查看可用余额、冻结余额和资金流水。</p>
|
||||
</div>
|
||||
|
||||
<div v-if="account" class="metric-grid">
|
||||
@@ -62,5 +75,17 @@ async function loadWallet() {
|
||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</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="loadLedgerPage"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -8,22 +8,30 @@ import { formatDateTime } from '@/utils/time'
|
||||
const loading = ref(false)
|
||||
const logs = ref<AdminAuditLog[]>([])
|
||||
const activeLog = ref<AdminAuditLog | null>(null)
|
||||
const currentPage = ref(1)
|
||||
const currentPageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const filters = reactive({
|
||||
actor_id: '',
|
||||
action: '',
|
||||
biz_type: '',
|
||||
limit: 200,
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
async function loadLogs() {
|
||||
loading.value = true
|
||||
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 {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -33,10 +41,15 @@ function resetFilters() {
|
||||
filters.actor_id = ''
|
||||
filters.action = ''
|
||||
filters.biz_type = ''
|
||||
filters.limit = 200
|
||||
currentPage.value = 1
|
||||
void loadLogs()
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
currentPage.value = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
function actorName(row: AdminAuditLog) {
|
||||
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">
|
||||
<p class="eyebrow">Audit Logs</p>
|
||||
<h1>审计日志</h1>
|
||||
<p>查看后台管理员操作、业务对象、来源 IP 和操作明细,用于追踪冻结、配置修改等高风险动作。</p>
|
||||
<p>查看后台管理员操作、业务对象和操作明细,用于追踪冻结、配置修改等高风险动作。</p>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
@@ -69,12 +82,8 @@ function actionType(action: string) {
|
||||
|
||||
<div class="metric-grid">
|
||||
<div class="metric-card">
|
||||
<span>当前结果</span>
|
||||
<strong>{{ logs.length }} 条</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>操作管理员</span>
|
||||
<strong>{{ uniqueActors }} 人</strong>
|
||||
<span>总条数</span>
|
||||
<strong>{{ total }} 条</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>高风险动作</span>
|
||||
@@ -94,13 +103,8 @@ function actionType(action: string) {
|
||||
<el-option label="商品标记异常" value="listing.mark_abnormal" />
|
||||
<el-option label="客服关闭订单" value="order.admin_close" />
|
||||
<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_checkout_confirm" />
|
||||
<el-option label="申诉仲裁" value="dispute.arbitrate" />
|
||||
<el-option label="更新系统配置" value="system_config.update" />
|
||||
<el-option label="创建系统配置" value="system_config.create" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="业务类型">
|
||||
@@ -112,17 +116,12 @@ function actionType(action: string) {
|
||||
<el-option label="系统配置" value="system_config" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="查询条数">
|
||||
<el-input-number v-model="filters.limit" :min="20" :max="500" :step="20" class="full-control" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table v-loading="loading" class="table-panel" :data="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 }">
|
||||
<strong>{{ actorName(row) }}</strong>
|
||||
<span class="table-subtext">{{ row.actor_type }} ID: {{ row.actor_id }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="动作" min-width="180">
|
||||
@@ -132,8 +131,6 @@ function actionType(action: string) {
|
||||
</el-table-column>
|
||||
<el-table-column prop="biz_type" label="业务类型" width="130" />
|
||||
<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">
|
||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
@@ -144,9 +141,23 @@ function actionType(action: string) {
|
||||
</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="loadLogs"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog :model-value="!!activeLog" title="审计明细" width="720px" @update:model-value="activeLog = null">
|
||||
<div v-if="activeLog" class="dialog-body">
|
||||
<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">
|
||||
<pre>{{ detailText(activeLog) }}</pre>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { onMounted, ref } from 'vue'
|
||||
import { arbitrateDispute, fetchAdminDisputes, type Dispute } from '@/api/disputes'
|
||||
import { fetchAdminFileBlob } from '@/api/files'
|
||||
import { disputeStatusLabel } from '@/utils/statusLabels'
|
||||
import { formatDateTime } from '@/utils/time'
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
@@ -14,18 +15,28 @@ const evidenceDispute = ref<Dispute | null>(null)
|
||||
const result = ref('release_deposit')
|
||||
const remark = ref('')
|
||||
const amount = ref<number | undefined>()
|
||||
const currentPage = ref(1)
|
||||
const currentPageSize = ref(20)
|
||||
const total = ref(0)
|
||||
|
||||
onMounted(loadDisputes)
|
||||
|
||||
async function loadDisputes() {
|
||||
loading.value = true
|
||||
try {
|
||||
disputes.value = await fetchAdminDisputes()
|
||||
const res = await fetchAdminDisputes(currentPage.value, currentPageSize.value)
|
||||
disputes.value = res.items
|
||||
total.value = res.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
currentPage.value = 1
|
||||
loadDisputes()
|
||||
}
|
||||
|
||||
function openArbitration(row: Dispute) {
|
||||
activeDispute.value = row
|
||||
result.value = row.arbitration_result || 'release_deposit'
|
||||
@@ -109,20 +120,30 @@ function readError(error: unknown, fallback: string) {
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">{{ disputeStatusLabel(row.status) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="说明" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="arbitration_result" label="结果" width="150" />
|
||||
<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 label="创建时间" min-width="180">
|
||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||
</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 }">
|
||||
<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>
|
||||
</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="loadDisputes"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog :model-value="!!activeDispute" title="申诉仲裁" width="560px" @update:model-value="activeDispute = null">
|
||||
<div v-if="activeDispute" class="dialog-body">
|
||||
<p><strong>{{ activeDispute.order_no }}</strong> · {{ activeDispute.title }}</p>
|
||||
|
||||
@@ -6,13 +6,8 @@ import { fetchAdminFileBlob } from '@/api/files'
|
||||
import { approveListing, fetchPendingReviewListings, rejectListing, type Listing } from '@/api/listings'
|
||||
import {
|
||||
formatMoney,
|
||||
formatRatio,
|
||||
getAdminListingPricing,
|
||||
listingAssetTags,
|
||||
listingBadgeTags,
|
||||
listingBuyerPrice,
|
||||
listingDisplayTitle,
|
||||
listingResourceTags,
|
||||
listingSellerPrice,
|
||||
ownerName,
|
||||
} from '@/views/admin/listingAdminDisplay'
|
||||
@@ -24,18 +19,28 @@ const listings = ref<Listing[]>([])
|
||||
const activeListing = ref<Listing | null>(null)
|
||||
const evidenceListing = ref<Listing | null>(null)
|
||||
const rejectReason = ref('')
|
||||
const currentPage = ref(1)
|
||||
const currentPageSize = ref(20)
|
||||
const total = ref(0)
|
||||
|
||||
onMounted(loadListings)
|
||||
|
||||
async function loadListings() {
|
||||
loading.value = true
|
||||
try {
|
||||
listings.value = await fetchPendingReviewListings()
|
||||
const result = await fetchPendingReviewListings(currentPage.value, currentPageSize.value)
|
||||
listings.value = result.items
|
||||
total.value = result.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
currentPage.value = 1
|
||||
loadListings()
|
||||
}
|
||||
|
||||
async function handleApprove(row: Listing) {
|
||||
submitting.value = true
|
||||
try {
|
||||
@@ -113,60 +118,23 @@ function readError(error: unknown, fallback: string) {
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" class="table-panel" :data="listings">
|
||||
<el-table-column label="商品" min-width="270" fixed="left">
|
||||
<el-table-column label="商品" min-width="220" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<strong>{{ listingDisplayTitle(row) }}</strong>
|
||||
<span class="table-subtext">{{ row.server_region }} / {{ row.login_platform }}</span>
|
||||
<div v-if="listingBadgeTags(row).length" class="admin-tag-row compact">
|
||||
<el-tag
|
||||
v-for="tag in listingBadgeTags(row)"
|
||||
:key="`${row.id}-${tag}`"
|
||||
size="small"
|
||||
:type="tag === '特惠' ? 'danger' : 'warning'"
|
||||
effect="plain"
|
||||
>
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="号主" min-width="150">
|
||||
<el-table-column label="号主" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<strong>{{ ownerName(row) }}</strong>
|
||||
<span class="table-subtext">ID: {{ row.owner_id }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="资产配置" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<div class="admin-tag-row">
|
||||
<el-tag v-for="tag in listingAssetTags(row).slice(0, 8)" :key="`${row.id}-${tag}`" size="small" effect="plain">
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="资源" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div v-if="listingResourceTags(row).length" class="admin-tag-row">
|
||||
<el-tag v-for="tag in listingResourceTags(row)" :key="`${row.id}-${tag}`" size="small" type="info" effect="plain">
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<span v-else class="table-subtext">无额外资源</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="价格" min-width="170">
|
||||
<template #default="{ row }">
|
||||
<strong>{{ listingBuyerPrice(row) }}</strong>
|
||||
<span class="table-subtext">卖家 {{ listingSellerPrice(row) }}</span>
|
||||
<span class="table-subtext">
|
||||
卖家 {{ formatRatio(getAdminListingPricing(row).sellerRatio) }} / 买家 {{ formatRatio(getAdminListingPricing(row).buyerRatio) }}
|
||||
</span>
|
||||
<span class="table-subtext">卖家 {{ listingSellerPrice(row) }} · 押金 {{ formatMoney(row.deposit_amount) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="押金" width="100">
|
||||
<template #default="{ row }">{{ formatMoney(row.deposit_amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="截图" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" :disabled="!row.screenshot_urls?.length" @click="openEvidence(row)">
|
||||
@@ -185,6 +153,18 @@ function readError(error: unknown, fallback: string) {
|
||||
</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="loadListings"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog :model-value="!!activeListing" title="拒绝发布" width="560px" @update:model-value="activeListing = null">
|
||||
<div v-if="activeListing" class="dialog-body">
|
||||
<p><strong>{{ listingDisplayTitle(activeListing) }}</strong></p>
|
||||
|
||||
@@ -5,14 +5,8 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { fetchAdminListings, type Listing } from '@/api/listings'
|
||||
import {
|
||||
formatMoney,
|
||||
formatRatio,
|
||||
getAdminListingPricing,
|
||||
listingAssetTags,
|
||||
listingBadgeTags,
|
||||
listingBuyerPrice,
|
||||
listingDisplayTitle,
|
||||
listingResourceTags,
|
||||
listingRiskTags,
|
||||
listingSellerPrice,
|
||||
ownerName,
|
||||
} from '@/views/admin/listingAdminDisplay'
|
||||
@@ -21,11 +15,13 @@ import { formatDateTime } from '@/utils/time'
|
||||
|
||||
const loading = ref(false)
|
||||
const listings = ref<Listing[]>([])
|
||||
const currentPage = ref(1)
|
||||
const currentPageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const filters = reactive({
|
||||
owner_id: '',
|
||||
status: '',
|
||||
review_status: '',
|
||||
limit: 200,
|
||||
})
|
||||
|
||||
const publishedCount = computed(() => listings.value.filter((item) => item.status === 'published').length)
|
||||
@@ -37,7 +33,14 @@ onMounted(loadListings)
|
||||
async function loadListings() {
|
||||
loading.value = true
|
||||
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 {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -47,10 +50,15 @@ function resetFilters() {
|
||||
filters.owner_id = ''
|
||||
filters.status = ''
|
||||
filters.review_status = ''
|
||||
filters.limit = 200
|
||||
currentPage.value = 1
|
||||
void loadListings()
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
currentPage.value = 1
|
||||
loadListings()
|
||||
}
|
||||
|
||||
function statusType(status: string) {
|
||||
if (status === 'published') return 'success'
|
||||
if (status === 'rented') return 'warning'
|
||||
@@ -72,7 +80,7 @@ function reviewType(status: string) {
|
||||
<div class="page-header">
|
||||
<p class="eyebrow">Listings</p>
|
||||
<h1>商品管理</h1>
|
||||
<p>查看全部租号商品、号主、区服平台、价格押金、上架状态和审核状态。</p>
|
||||
<p>查看全部租号商品、号主、价格押金、上架状态和审核状态。</p>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
@@ -82,8 +90,8 @@ function reviewType(status: string) {
|
||||
|
||||
<div class="metric-grid">
|
||||
<div class="metric-card">
|
||||
<span>当前结果</span>
|
||||
<strong>{{ listings.length }} 个</strong>
|
||||
<span>总条数</span>
|
||||
<strong>{{ total }} 个</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>已上架</span>
|
||||
@@ -120,81 +128,21 @@ function reviewType(status: string) {
|
||||
<el-option label="已拒绝" value="rejected" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="查询条数">
|
||||
<el-input-number v-model="filters.limit" :min="20" :max="500" :step="20" class="full-control" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table v-loading="loading" class="table-panel" :data="listings">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column label="商品" min-width="270" fixed="left">
|
||||
<el-table-column label="商品" min-width="220" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<strong>{{ listingDisplayTitle(row) }}</strong>
|
||||
<span class="table-subtext">账号 ID: {{ row.account_id }}</span>
|
||||
<div v-if="listingBadgeTags(row).length" class="admin-tag-row compact">
|
||||
<el-tag
|
||||
v-for="tag in listingBadgeTags(row)"
|
||||
:key="`${row.id}-${tag}`"
|
||||
size="small"
|
||||
:type="tag === '特惠' ? 'danger' : 'warning'"
|
||||
effect="plain"
|
||||
>
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<span class="table-subtext">{{ row.game_name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="号主" min-width="150">
|
||||
<el-table-column label="号主" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<strong>{{ ownerName(row) }}</strong>
|
||||
<span class="table-subtext">ID: {{ row.owner_id }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="资产配置" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<div class="admin-tag-row">
|
||||
<el-tag v-for="tag in listingAssetTags(row).slice(0, 8)" :key="`${row.id}-${tag}`" size="small" effect="plain">
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="资源" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div v-if="listingResourceTags(row).length" class="admin-tag-row">
|
||||
<el-tag v-for="tag in listingResourceTags(row)" :key="`${row.id}-${tag}`" size="small" type="info" effect="plain">
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<span v-else class="table-subtext">无额外资源</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="交接风控" min-width="230">
|
||||
<template #default="{ row }">
|
||||
<div class="admin-tag-row">
|
||||
<el-tag v-for="tag in listingRiskTags(row)" :key="`${row.id}-${tag}`" size="small" type="info" effect="plain">
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<span class="table-subtext">{{ row.server_region }} / {{ row.login_platform }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="价格" min-width="170">
|
||||
<template #default="{ row }">
|
||||
<strong>{{ listingBuyerPrice(row) }}</strong>
|
||||
<span class="table-subtext">卖家 {{ listingSellerPrice(row) }}</span>
|
||||
<span class="table-subtext">
|
||||
卖家 {{ formatRatio(getAdminListingPricing(row).sellerRatio) }} / 买家 {{ formatRatio(getAdminListingPricing(row).buyerRatio) }}
|
||||
</span>
|
||||
<span v-if="getAdminListingPricing(row).platformMarkup > 0" class="table-subtext">
|
||||
平台加价 {{ formatMoney(getAdminListingPricing(row).platformMarkup) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="押金" width="100">
|
||||
<template #default="{ row }">{{ formatMoney(row.deposit_amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="商品状态" width="110">
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusType(row.status)">{{ listingStatusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
@@ -204,11 +152,14 @@ function reviewType(status: string) {
|
||||
<el-tag :type="reviewType(row.review_status)">{{ listingReviewStatusLabel(row.review_status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上架时间" min-width="180">
|
||||
<template #default="{ row }">{{ formatDateTime(row.published_at) }}</template>
|
||||
<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 label="更新时间" min-width="180">
|
||||
<template #default="{ row }">{{ formatDateTime(row.updated_at) }}</template>
|
||||
<el-table-column label="创建时间" min-width="180">
|
||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="110">
|
||||
<template #default="{ row }">
|
||||
@@ -218,5 +169,17 @@ function reviewType(status: string) {
|
||||
</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="loadListings"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
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 { formatDateTime } from '@/utils/time'
|
||||
|
||||
const loading = ref(false)
|
||||
const orders = ref<Order[]>([])
|
||||
const orders = ref<OrderListItem[]>([])
|
||||
const status = ref('')
|
||||
const currentPage = ref(1)
|
||||
const currentPageSize = ref(20)
|
||||
const total = ref(0)
|
||||
|
||||
const filteredOrders = computed(() => {
|
||||
if (!status.value) return orders.value
|
||||
@@ -19,11 +22,18 @@ onMounted(loadOrders)
|
||||
async function loadOrders() {
|
||||
loading.value = true
|
||||
try {
|
||||
orders.value = await fetchAdminOrders()
|
||||
const result = await fetchAdminOrders(currentPage.value, currentPageSize.value)
|
||||
orders.value = result.items
|
||||
total.value = result.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
currentPage.value = 1
|
||||
loadOrders()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -55,20 +65,30 @@ async function loadOrders() {
|
||||
|
||||
<el-table v-loading="loading" class="table-panel" :data="filteredOrders">
|
||||
<el-table-column prop="order_no" label="订单号" min-width="230" />
|
||||
<el-table-column prop="title" label="账号" min-width="170" />
|
||||
<el-table-column prop="renter_phone" label="租客" min-width="130" />
|
||||
<el-table-column prop="owner_phone" label="号主" min-width="130" />
|
||||
<el-table-column label="状态" width="140">
|
||||
<template #default="{ row }">{{ orderStatusLabel(row.status) }}</template>
|
||||
<el-table-column prop="listing_title" label="商品名" min-width="170" />
|
||||
<el-table-column label="租客/号主" min-width="160">
|
||||
<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="180">
|
||||
<template #default="{ row }">{{ handoffStatusLabel(row.handoff_status) }}</template>
|
||||
<el-table-column label="状态" min-width="160">
|
||||
<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 label="结算" width="120">
|
||||
<template #default="{ row }">{{ settlementStatusLabel(row.settlement_status) }}</template>
|
||||
<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 prop="rent_amount" label="订单金额" width="100" />
|
||||
<el-table-column prop="deposit_amount" label="押金" width="100" />
|
||||
<el-table-column label="创建时间" min-width="180">
|
||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
@@ -80,5 +100,17 @@ async function loadOrders() {
|
||||
</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>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ElMessage } from 'element-plus'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
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'
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -11,18 +11,28 @@ const submitting = ref(false)
|
||||
const users = ref<AdminUserItem[]>([])
|
||||
const activeUser = ref<AdminUserItem | null>(null)
|
||||
const freezeReason = ref('')
|
||||
const currentPage = ref(1)
|
||||
const currentPageSize = ref(20)
|
||||
const total = ref(0)
|
||||
|
||||
onMounted(loadUsers)
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
try {
|
||||
users.value = await fetchAdminUsers()
|
||||
const result = await fetchAdminUsers(currentPage.value, currentPageSize.value)
|
||||
users.value = result.items
|
||||
total.value = result.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
currentPage.value = 1
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
function openFreeze(row: AdminUserItem) {
|
||||
activeUser.value = row
|
||||
freezeReason.value = ''
|
||||
@@ -71,30 +81,20 @@ function readError(error: unknown, fallback: string) {
|
||||
<div class="page-header">
|
||||
<p class="eyebrow">Users</p>
|
||||
<h1>用户管理</h1>
|
||||
<p>查看用户实名、风险、信用和业务数量,处理冻结与解冻。</p>
|
||||
<p>查看用户信息和状态,处理冻结与解冻。</p>
|
||||
</div>
|
||||
<el-button @click="loadUsers">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" class="table-panel" :data="users">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="phone" label="手机号" min-width="130" />
|
||||
<el-table-column prop="id" label="用户ID" width="80" />
|
||||
<el-table-column prop="nickname" label="昵称" min-width="130" />
|
||||
<el-table-column label="实名" width="110">
|
||||
<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 prop="phone" label="手机号" min-width="130" />
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">{{ userStatusLabel(row.status) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="order_count" label="订单" width="90" />
|
||||
<el-table-column prop="listing_count" label="发布" width="90" />
|
||||
<el-table-column prop="dispute_count" label="申诉" width="90" />
|
||||
<el-table-column label="最近登录" min-width="180">
|
||||
<template #default="{ row }">{{ formatDateTime(row.last_login_at) }}</template>
|
||||
<el-table-column label="注册时间" min-width="180">
|
||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
@@ -106,6 +106,18 @@ function readError(error: unknown, fallback: string) {
|
||||
</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="loadUsers"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog :model-value="!!activeUser" title="冻结用户" width="560px" @update:model-value="activeUser = null">
|
||||
<div v-if="activeUser" class="dialog-body">
|
||||
<p><strong>{{ activeUser.phone }}</strong> · {{ activeUser.nickname }}</p>
|
||||
|
||||
@@ -8,11 +8,13 @@ import { formatDateTime } from '@/utils/time'
|
||||
|
||||
const loading = ref(false)
|
||||
const ledger = ref<AdminWalletLedger[]>([])
|
||||
const currentPage = ref(1)
|
||||
const currentPageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const filters = reactive({
|
||||
user_id: '',
|
||||
order_id: '',
|
||||
biz_type: '',
|
||||
limit: 200,
|
||||
})
|
||||
|
||||
const inAmount = computed(() =>
|
||||
@@ -21,16 +23,20 @@ const inAmount = computed(() =>
|
||||
const outAmount = computed(() =>
|
||||
ledger.value.filter((item) => item.direction === 'out').reduce((sum, item) => sum + Number(item.amount || 0), 0),
|
||||
)
|
||||
const frozenAmount = computed(() =>
|
||||
ledger.value.filter((item) => item.balance_type === 'frozen').reduce((sum, item) => sum + Number(item.amount || 0), 0),
|
||||
)
|
||||
|
||||
onMounted(loadLedger)
|
||||
|
||||
async function loadLedger() {
|
||||
loading.value = true
|
||||
try {
|
||||
ledger.value = await fetchAdminWalletLedger(filters)
|
||||
const query = {
|
||||
...filters,
|
||||
page: currentPage.value,
|
||||
page_size: currentPageSize.value,
|
||||
}
|
||||
const result = await fetchAdminWalletLedger(query)
|
||||
ledger.value = result.items
|
||||
total.value = result.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -40,10 +46,15 @@ function resetFilters() {
|
||||
filters.user_id = ''
|
||||
filters.order_id = ''
|
||||
filters.biz_type = ''
|
||||
filters.limit = 200
|
||||
currentPage.value = 1
|
||||
void loadLedger()
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
currentPage.value = 1
|
||||
loadLedger()
|
||||
}
|
||||
|
||||
function money(value: number) {
|
||||
return `¥${value.toFixed(2)}`
|
||||
}
|
||||
@@ -65,7 +76,7 @@ function directionLabel(direction: string) {
|
||||
<div class="page-header">
|
||||
<p class="eyebrow">Wallet Ledger</p>
|
||||
<h1>资金流水</h1>
|
||||
<p>查看订单金额、押金、冻结、解冻、退款和结算流水。当前为开发态账务记录,不代表真实支付余额。</p>
|
||||
<p>查看订单金额、押金、冻结、解冻、退款和结算流水。</p>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
@@ -75,8 +86,8 @@ function directionLabel(direction: string) {
|
||||
|
||||
<div class="metric-grid">
|
||||
<div class="metric-card">
|
||||
<span>当前结果</span>
|
||||
<strong>{{ ledger.length }} 笔</strong>
|
||||
<span>总条数</span>
|
||||
<strong>{{ total }} 笔</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>入账合计</span>
|
||||
@@ -86,10 +97,6 @@ function directionLabel(direction: string) {
|
||||
<span>出账合计</span>
|
||||
<strong>{{ money(outAmount) }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>冻结相关金额</span>
|
||||
<strong>{{ money(frozenAmount) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form class="filter-panel" label-position="top">
|
||||
@@ -107,20 +114,16 @@ function directionLabel(direction: string) {
|
||||
<el-option label="押金退回" value="deposit_refund" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="查询条数">
|
||||
<el-input-number v-model="filters.limit" :min="20" :max="500" :step="20" class="full-control" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table v-loading="loading" class="table-panel" :data="ledger">
|
||||
<el-table-column prop="ledger_no" label="流水号" min-width="230" />
|
||||
<el-table-column label="用户" min-width="160">
|
||||
<el-table-column label="用户" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<strong>{{ row.user_phone || `用户 ${row.user_id}` }}</strong>
|
||||
<span class="table-subtext">ID: {{ row.user_id }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="订单" min-width="180">
|
||||
<el-table-column label="订单" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<RouterLink v-if="row.order_id" :to="`/admin/orders/${row.order_id}`">{{ row.order_no || row.order_id }}</RouterLink>
|
||||
<span v-else>-</span>
|
||||
@@ -141,10 +144,21 @@ function directionLabel(direction: string) {
|
||||
<el-table-column label="变化后余额" width="130">
|
||||
<template #default="{ row }">{{ money(Number(row.balance_after)) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="220" />
|
||||
<el-table-column label="创建时间" min-width="180">
|
||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</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="loadLedger"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -58,7 +58,10 @@ const sortOptions = [
|
||||
{ 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: [
|
||||
{ label: "50-100", min: "50", max: "100" },
|
||||
{ label: "100-200", min: "100", max: "200" },
|
||||
@@ -98,13 +101,52 @@ const loginMethodFilterOptions = computed(() =>
|
||||
);
|
||||
|
||||
const filterSections = computed<FilterSection[]>(() => [
|
||||
{ key: "price", title: "价格区间", type: "range", unit: "元", minPlaceholder: "最低价", 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 },
|
||||
{
|
||||
key: "price",
|
||||
title: "价格区间",
|
||||
type: "range",
|
||||
unit: "元",
|
||||
minPlaceholder: "最低价",
|
||||
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) => ({
|
||||
key: `resource_${item.key}`,
|
||||
title: item.label,
|
||||
@@ -119,9 +161,27 @@ const filterSections = computed<FilterSection[]>(() => [
|
||||
type: "chips" as const,
|
||||
options: group.options,
|
||||
})),
|
||||
{ key: "secretKd", title: "绝密KD", type: "range", minPlaceholder: "最低", maxPlaceholder: "最高" },
|
||||
{ key: "rank", title: "段位", type: "chips", options: publishOptions.value.rank_options },
|
||||
{ key: "deposit", title: "押金", type: "range", unit: "元", minPlaceholder: "最低", maxPlaceholder: "最高" },
|
||||
{
|
||||
key: "secretKd",
|
||||
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(() => {
|
||||
@@ -160,7 +220,8 @@ async function loadListings() {
|
||||
loading.value = true;
|
||||
loadFailed.value = false;
|
||||
try {
|
||||
listings.value = await fetchListings();
|
||||
const res = await fetchListings();
|
||||
listings.value = res.items;
|
||||
} catch {
|
||||
loadFailed.value = true;
|
||||
} finally {
|
||||
@@ -188,7 +249,7 @@ async function onRefresh() {
|
||||
fetchListings(),
|
||||
fetchMobileHomeConfig(),
|
||||
]);
|
||||
listings.value = nextListings;
|
||||
listings.value = nextListings.items;
|
||||
announcements.value = nextHomeConfig.announcements;
|
||||
bannerSlides.value = nextHomeConfig.banners;
|
||||
publishOptions.value = nextHomeConfig.publish_options;
|
||||
@@ -243,19 +304,24 @@ function sortListings(items: Listing[]) {
|
||||
}
|
||||
|
||||
function matchesFilters(item: Listing) {
|
||||
const chipOk = Object.entries(selectedFilters.value).every(([key, values]) => {
|
||||
const chipOk = Object.entries(selectedFilters.value).every(
|
||||
([key, values]) => {
|
||||
if (!values.length) return true;
|
||||
if (key === "server") return values.includes(getServerRegion(item));
|
||||
if (key === "login") return values.includes(getLoginMethod(item));
|
||||
if (key === "insurance") return values.includes(readAssetString(item, "season_insurance"));
|
||||
if (key === "stamina") return values.includes(readAssetString(item, "stamina_level"));
|
||||
if (key === "load") return values.includes(readAssetString(item, "load_level"));
|
||||
if (key === "insurance")
|
||||
return values.includes(readAssetString(item, "season_insurance"));
|
||||
if (key === "stamina")
|
||||
return values.includes(readAssetString(item, "stamina_level"));
|
||||
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;
|
||||
});
|
||||
}
|
||||
);
|
||||
if (!chipOk) return false;
|
||||
|
||||
return Object.entries(rangeFilters.value).every(([key, range]) => {
|
||||
@@ -312,7 +378,6 @@ function parseQuantityUnit(price: string) {
|
||||
function uniqueOptions(values: string[]) {
|
||||
return [...new Set(values.map((item) => item.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -378,7 +443,9 @@ function uniqueOptions(values: string[]) {
|
||||
<h1 v-if="slide.title">{{ slide.title }}</h1>
|
||||
<span v-if="slide.pill">{{ slide.pill }}</span>
|
||||
</div>
|
||||
<div v-if="slide.badge" class="banner-badge">{{ slide.badge }}</div>
|
||||
<div v-if="slide.badge" class="banner-badge">
|
||||
{{ slide.badge }}
|
||||
</div>
|
||||
</div>
|
||||
</van-swipe-item>
|
||||
</van-swipe>
|
||||
|
||||
@@ -3,14 +3,17 @@ import { onMounted, ref, computed } from "vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import { showToast } from "vant";
|
||||
|
||||
import { fetchOrders, type Order } from "@/api/orders";
|
||||
import { fetchOrders, type OrderListItem } from "@/api/orders";
|
||||
import { formatDateMinute } from "@/utils/time";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const loading = ref(false);
|
||||
const orders = ref<Order[]>([]);
|
||||
const orders = ref<OrderListItem[]>([]);
|
||||
const activeTab = ref("all");
|
||||
const currentPage = ref(1);
|
||||
const currentPageSize = ref(20);
|
||||
const total = ref(0);
|
||||
|
||||
/** 判断当前底部导航是否激活 */
|
||||
function isNavActive(path: string) {
|
||||
@@ -23,7 +26,9 @@ onMounted(loadOrders);
|
||||
async function loadOrders() {
|
||||
loading.value = true;
|
||||
try {
|
||||
orders.value = await fetchOrders();
|
||||
const result = await fetchOrders(currentPage.value, currentPageSize.value);
|
||||
orders.value = result.items;
|
||||
total.value = result.total;
|
||||
} catch {
|
||||
showToast({ message: "订单加载失败,请稍后重试", icon: "warning-o" });
|
||||
} finally {
|
||||
@@ -31,6 +36,13 @@ async function loadOrders() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleLoadMore() {
|
||||
if (orders.value.length < total.value) {
|
||||
currentPage.value++;
|
||||
loadOrders();
|
||||
}
|
||||
}
|
||||
|
||||
/* 状态筛选 */
|
||||
const statusTabs = [
|
||||
{ key: "all", label: "全部" },
|
||||
@@ -141,7 +153,7 @@ function goDetail(id: number) {
|
||||
|
||||
<!-- 卡片体:关键信息 -->
|
||||
<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-item">
|
||||
<span class="info-label">订单金额</span>
|
||||
@@ -152,8 +164,8 @@ function goDetail(id: number) {
|
||||
<span class="info-value">¥{{ order.deposit_amount }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">区服</span>
|
||||
<span class="info-value">{{ order.server_region }}</span>
|
||||
<span class="info-label">游戏</span>
|
||||
<span class="info-value">{{ order.game_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,6 +176,13 @@ function goDetail(id: number) {
|
||||
<span class="detail-link">查看详情 ›</span>
|
||||
</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>
|
||||
|
||||
<!-- 底部导航 -->
|
||||
@@ -396,6 +415,12 @@ function goDetail(id: number) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.load-more-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
/* ========== 底部导航 ========== */
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
|
||||
@@ -78,11 +78,14 @@ const visibleListings = computed(() => {
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
if (keyword && !text.includes(keyword)) return false;
|
||||
if (filters.server && getServerRegion(item) !== filters.server) return false;
|
||||
if (filters.loginMethod && getLoginMethod(item) !== filters.loginMethod) return false;
|
||||
if (filters.server && getServerRegion(item) !== filters.server)
|
||||
return false;
|
||||
if (filters.loginMethod && getLoginMethod(item) !== filters.loginMethod)
|
||||
return false;
|
||||
if (filters.rank && item.rank_level !== filters.rank) 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;
|
||||
});
|
||||
|
||||
@@ -103,7 +106,11 @@ const visibleListings = computed(() => {
|
||||
});
|
||||
|
||||
const statCards = computed(() => [
|
||||
{ label: "可租账号", value: `${visibleListings.value.length}`, hint: "当前筛选结果" },
|
||||
{
|
||||
label: "可租账号",
|
||||
value: `${visibleListings.value.length}`,
|
||||
hint: "当前筛选结果",
|
||||
},
|
||||
{
|
||||
label: "高哈夫币",
|
||||
value: `${listings.value.filter((item) => getCoinM(item) >= 100).length}`,
|
||||
@@ -111,7 +118,10 @@ const statCards = computed(() => [
|
||||
},
|
||||
{
|
||||
label: "账密登录",
|
||||
value: `${listings.value.filter((item) => getLoginMethod(item) === "账号密码").length}`,
|
||||
value: `${
|
||||
listings.value.filter((item) => getLoginMethod(item) === "账号密码")
|
||||
.length
|
||||
}`,
|
||||
hint: "交接更快",
|
||||
},
|
||||
]);
|
||||
@@ -125,7 +135,7 @@ async function loadHome() {
|
||||
fetchListings(),
|
||||
fetchMobileHomeConfig(),
|
||||
]);
|
||||
listings.value = nextListings;
|
||||
listings.value = nextListings.items;
|
||||
announcements.value = config.announcements;
|
||||
banners.value = config.banners;
|
||||
publishOptions.value = config.publish_options;
|
||||
@@ -165,7 +175,10 @@ function uniqueOptions(values: string[]) {
|
||||
</div>
|
||||
<div class="home-searchbar">
|
||||
<el-icon><Search /></el-icon>
|
||||
<input v-model="filters.keyword" placeholder="搜区服 / 段位 / 哈夫币 / 皮肤" />
|
||||
<input
|
||||
v-model="filters.keyword"
|
||||
placeholder="搜区服 / 段位 / 哈夫币 / 皮肤"
|
||||
/>
|
||||
</div>
|
||||
<RouterLink class="home-service" to="/notifications">
|
||||
<el-icon><Headset /></el-icon>
|
||||
@@ -205,13 +218,27 @@ function uniqueOptions(values: string[]) {
|
||||
<label>
|
||||
<span>区服</span>
|
||||
<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>
|
||||
</label>
|
||||
<label>
|
||||
<span>上号方式</span>
|
||||
<el-select v-model="filters.loginMethod" clearable placeholder="全部方式">
|
||||
<el-option v-for="item in loginMethodOptions" :key="item" :label="item" :value="item" />
|
||||
<el-select
|
||||
v-model="filters.loginMethod"
|
||||
clearable
|
||||
placeholder="全部方式"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in loginMethodOptions"
|
||||
:key="item"
|
||||
:label="item"
|
||||
:value="item"
|
||||
/>
|
||||
</el-select>
|
||||
</label>
|
||||
<label>
|
||||
@@ -249,18 +276,30 @@ function uniqueOptions(values: string[]) {
|
||||
<div class="quick-chip-group">
|
||||
<button type="button" @click="filters.minCoin = 100">100M+</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>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="home-main">
|
||||
<section class="hero-board">
|
||||
<el-carousel height="210px" indicator-position="outside" :interval="3600">
|
||||
<el-carousel-item v-for="slide in banners" :key="slide.title || slide.image_url">
|
||||
<el-carousel
|
||||
height="210px"
|
||||
indicator-position="outside"
|
||||
:interval="3600"
|
||||
>
|
||||
<el-carousel-item
|
||||
v-for="slide in banners"
|
||||
:key="slide.title || slide.image_url"
|
||||
>
|
||||
<div
|
||||
class="hero-slide"
|
||||
:class="[`tone-${slide.tone}`, { 'has-image': slide.image_url }]"
|
||||
:class="[
|
||||
`tone-${slide.tone}`,
|
||||
{ 'has-image': slide.image_url },
|
||||
]"
|
||||
>
|
||||
<img
|
||||
v-if="slide.image_url"
|
||||
@@ -335,7 +374,13 @@ function uniqueOptions(values: string[]) {
|
||||
<div class="desktop-title">
|
||||
<strong>{{ getListingTitle(item) }}</strong>
|
||||
</div>
|
||||
<p>{{ getListingSubtitle(item) || item.description || "平台担保交接,资料以截图为准。" }}</p>
|
||||
<p>
|
||||
{{
|
||||
getListingSubtitle(item) ||
|
||||
item.description ||
|
||||
"平台担保交接,资料以截图为准。"
|
||||
}}
|
||||
</p>
|
||||
<div class="desktop-tags">
|
||||
<span>{{ getServerRegion(item) }}</span>
|
||||
<span>{{ getLoginMethod(item) }}</span>
|
||||
@@ -598,7 +643,11 @@ function uniqueOptions(values: string[]) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
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 {
|
||||
|
||||
@@ -1,112 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { Refresh, Search } from "@element-plus/icons-vue";
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Refresh, Search } from '@element-plus/icons-vue'
|
||||
|
||||
import { fetchListings, type Listing } from "@/api/listings";
|
||||
import { listingStatusLabel } from "@/utils/statusLabels";
|
||||
import { fetchListings, type Listing } from '@/api/listings'
|
||||
import { listingStatusLabel } from '@/utils/statusLabels'
|
||||
|
||||
const loading = ref(false);
|
||||
const listings = ref<Listing[]>([]);
|
||||
const sortBy = ref("recommended");
|
||||
const loading = ref(false)
|
||||
const listings = ref<Listing[]>([])
|
||||
const sortBy = ref('recommended')
|
||||
const currentPage = ref(1)
|
||||
const currentPageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const filters = reactive({
|
||||
keyword: "",
|
||||
region: "",
|
||||
platform: "",
|
||||
keyword: '',
|
||||
region: '',
|
||||
platform: '',
|
||||
minCoin: undefined as number | undefined,
|
||||
maxPrice: undefined as number | undefined,
|
||||
});
|
||||
})
|
||||
|
||||
const regionOptions = computed(() =>
|
||||
uniqueOptions(listings.value.map((item) => item.server_region))
|
||||
);
|
||||
)
|
||||
const platformOptions = computed(() =>
|
||||
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) => {
|
||||
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);
|
||||
onMounted(loadListings)
|
||||
|
||||
async function loadListings() {
|
||||
loading.value = true;
|
||||
loading.value = true
|
||||
try {
|
||||
listings.value = await fetchListings();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
// 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 {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
currentPage.value = 1
|
||||
loadListings()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.keyword = "";
|
||||
filters.region = "";
|
||||
filters.platform = "";
|
||||
filters.minCoin = undefined;
|
||||
filters.maxPrice = undefined;
|
||||
sortBy.value = "recommended";
|
||||
filters.keyword = ''
|
||||
filters.region = ''
|
||||
filters.platform = ''
|
||||
filters.minCoin = undefined
|
||||
filters.maxPrice = undefined
|
||||
sortBy.value = 'recommended'
|
||||
currentPage.value = 1
|
||||
loadListings()
|
||||
}
|
||||
|
||||
function uniqueOptions(values: string[]) {
|
||||
return [...new Set(values.filter(Boolean))];
|
||||
return [...new Set(values.filter(Boolean))]
|
||||
}
|
||||
|
||||
function listingPrice(item: Listing) {
|
||||
return Number(item.price_daily || item.price_hourly || 0);
|
||||
return Number(item.price_daily || item.price_hourly || 0)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pc-listings page">
|
||||
<div class="anti-fraud-strip compact">
|
||||
<span
|
||||
>防骗提示:只在平台内沟通、下单和确认结账,谨防冒充客服与低价私单。</span
|
||||
>
|
||||
<span>防骗提示:只在平台内沟通、下单和确认结账,谨防冒充客服与低价私单。</span>
|
||||
</div>
|
||||
|
||||
<div class="page-header-row listings-header">
|
||||
<div>
|
||||
<p class="eyebrow">Account Market</p>
|
||||
<h1>租号大厅</h1>
|
||||
<p>参考大锤商行 PC 信息密度,支持高级筛选、排序和卡片式资源浏览。</p>
|
||||
<p>支持高级筛选、排序和卡片式资源浏览。</p>
|
||||
</div>
|
||||
<RouterLink class="pc-post-link large" to="/seller/listings/create"
|
||||
>账号发布</RouterLink
|
||||
>
|
||||
<RouterLink class="pc-post-link large" to="/seller/listings/create">账号发布</RouterLink>
|
||||
</div>
|
||||
|
||||
<section class="advanced-filter-card">
|
||||
<div class="filter-title">
|
||||
<strong>高级筛选</strong>
|
||||
<span>共 {{ filteredListings.length }} 个可租账号</span>
|
||||
<span>共 {{ total }} 个可租账号</span>
|
||||
</div>
|
||||
<el-form label-position="top" class="pc-filter-grid">
|
||||
<el-form-item label="关键词">
|
||||
@@ -172,12 +160,12 @@ function listingPrice(item: Listing) {
|
||||
</section>
|
||||
|
||||
<el-empty
|
||||
v-if="!loading && filteredListings.length === 0"
|
||||
v-if="!loading && listings.length === 0"
|
||||
description="暂无符合条件的租号"
|
||||
/>
|
||||
<div v-else v-loading="loading" class="pc-resource-list">
|
||||
<RouterLink
|
||||
v-for="item in filteredListings"
|
||||
v-for="item in listings"
|
||||
:key="item.id"
|
||||
class="resource-card"
|
||||
:to="`/listings/${item.id}`"
|
||||
@@ -193,17 +181,17 @@ function listingPrice(item: Listing) {
|
||||
<div class="resource-main">
|
||||
<div class="resource-title-line">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<em>{{ item.status === "published" ? "可租" : listingStatusLabel(item.status) }}</em>
|
||||
<em>{{ item.status === 'published' ? '可租' : listingStatusLabel(item.status) }}</em>
|
||||
</div>
|
||||
<p>
|
||||
{{
|
||||
item.description || "号主暂未填写说明,可下单后按平台流程交接。"
|
||||
item.description || '号主暂未填写说明,可下单后按平台流程交接。'
|
||||
}}
|
||||
</p>
|
||||
<div class="resource-tags">
|
||||
<span>{{ item.server_region }}</span>
|
||||
<span>{{ item.login_platform }}</span>
|
||||
<span>{{ item.rank_level || "未填写段位" }}</span>
|
||||
<span>{{ item.rank_level || '未填写段位' }}</span>
|
||||
<span>哈夫币 {{ item.haf_coin_amount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -214,5 +202,17 @@ function listingPrice(item: Listing) {
|
||||
</div>
|
||||
</RouterLink>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -1,3 +1,56 @@
|
||||
<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>
|
||||
<section class="page">
|
||||
<div class="page-header">
|
||||
@@ -5,5 +58,69 @@
|
||||
<h1>交接管理</h1>
|
||||
<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>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-bar {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,18 +7,28 @@ import { listingReviewStatusLabel, listingStatusLabel } from '@/utils/statusLabe
|
||||
|
||||
const loading = ref(false)
|
||||
const listings = ref<Listing[]>([])
|
||||
const currentPage = ref(1)
|
||||
const currentPageSize = ref(20)
|
||||
const total = ref(0)
|
||||
|
||||
onMounted(loadListings)
|
||||
|
||||
async function loadListings() {
|
||||
loading.value = true
|
||||
try {
|
||||
listings.value = await fetchSellerListings()
|
||||
const result = await fetchSellerListings(currentPage.value, currentPageSize.value)
|
||||
listings.value = result.items
|
||||
total.value = result.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
currentPage.value = 1
|
||||
loadListings()
|
||||
}
|
||||
|
||||
async function submitReview(id: number) {
|
||||
const listing = await submitListingReview(id)
|
||||
ElMessage.success(
|
||||
@@ -65,7 +75,6 @@ function readSellerPrice(row: Listing) {
|
||||
<el-table v-loading="loading" class="table-panel" :data="listings">
|
||||
<el-table-column prop="title" label="标题" min-width="180" />
|
||||
<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">
|
||||
<template #default="{ row }">{{ listingPrice(row) }}</template>
|
||||
</el-table-column>
|
||||
@@ -90,5 +99,17 @@ function readSellerPrice(row: Listing) {
|
||||
</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="loadListings"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user