From abc4447401405506cb20845d22f62325cae1a292 Mon Sep 17 00:00:00 2001 From: yml Date: Sun, 24 May 2026 17:56:26 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=90=8E=E5=8F=B0=E5=88=86?= =?UTF-8?q?=E9=A1=B5=E4=B8=8E=E4=BB=A4=E7=89=8C=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/modules/adminauth/handler.go | 20 +++ backend/internal/modules/adminauth/service.go | 27 +++- backend/internal/modules/adminuser/dto.go | 8 +- backend/internal/modules/adminuser/handler.go | 20 ++- .../internal/modules/adminuser/repository.go | 11 +- backend/internal/modules/adminuser/service.go | 4 +- backend/internal/modules/dispute/dto.go | 8 +- backend/internal/modules/dispute/handler.go | 25 +++- .../internal/modules/dispute/repository.go | 70 ++++++++-- backend/internal/modules/dispute/service.go | 8 +- backend/internal/router/router.go | 3 +- frontend/src/api/adminAuth.ts | 9 ++ frontend/src/api/adminUsers.ts | 10 +- frontend/src/api/auth.ts | 8 ++ frontend/src/api/client.ts | 123 ++++++++++++++---- frontend/src/api/disputes.ts | 18 ++- frontend/src/router/index.ts | 25 +++- .../src/views/admin/AdminDisputesView.vue | 40 ++++-- frontend/src/views/admin/AdminUsersView.vue | 46 ++++--- 19 files changed, 392 insertions(+), 91 deletions(-) diff --git a/backend/internal/modules/adminauth/handler.go b/backend/internal/modules/adminauth/handler.go index 498e681..2c8e9eb 100644 --- a/backend/internal/modules/adminauth/handler.go +++ b/backend/internal/modules/adminauth/handler.go @@ -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): diff --git a/backend/internal/modules/adminauth/service.go b/backend/internal/modules/adminauth/service.go index 0866b74..917eccc 100644 --- a/backend/internal/modules/adminauth/service.go +++ b/backend/internal/modules/adminauth/service.go @@ -1,20 +1,41 @@ 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) *Service { - return &Service{repo: repo} +func NewService(repo *Repository, jwt *auth.JWTManager) *Service { + return &Service{repo: repo, jwt: jwt} +} + +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) { diff --git a/backend/internal/modules/adminuser/dto.go b/backend/internal/modules/adminuser/dto.go index 7502899..cfb9834 100644 --- a/backend/internal/modules/adminuser/dto.go +++ b/backend/internal/modules/adminuser/dto.go @@ -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"` +} diff --git a/backend/internal/modules/adminuser/handler.go b/backend/internal/modules/adminuser/handler.go index 3d0cb97..95e8d69 100644 --- a/backend/internal/modules/adminuser/handler.go +++ b/backend/internal/modules/adminuser/handler.go @@ -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) { diff --git a/backend/internal/modules/adminuser/repository.go b/backend/internal/modules/adminuser/repository.go index 4cfa59e..c34544a 100644 --- a/backend/internal/modules/adminuser/repository.go +++ b/backend/internal/modules/adminuser/repository.go @@ -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) { diff --git a/backend/internal/modules/adminuser/service.go b/backend/internal/modules/adminuser/service.go index 95bc002..4ade08d 100644 --- a/backend/internal/modules/adminuser/service.go +++ b/backend/internal/modules/adminuser/service.go @@ -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) { diff --git a/backend/internal/modules/dispute/dto.go b/backend/internal/modules/dispute/dto.go index 4fcf3ce..1faa778 100644 --- a/backend/internal/modules/dispute/dto.go +++ b/backend/internal/modules/dispute/dto.go @@ -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"` +} diff --git a/backend/internal/modules/dispute/handler.go b/backend/internal/modules/dispute/handler.go index 6cce4d0..f6c626d 100644 --- a/backend/internal/modules/dispute/handler.go +++ b/backend/internal/modules/dispute/handler.go @@ -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): diff --git a/backend/internal/modules/dispute/repository.go b/backend/internal/modules/dispute/repository.go index 1933929..94a7cdc 100644 --- a/backend/internal/modules/dispute/repository.go +++ b/backend/internal/modules/dispute/repository.go @@ -35,6 +35,7 @@ func (r *Repository) Create(userID uint64, orderID uint64, req CreateRequest) (* if order.Status == "completed" || order.Status == "cancelled" || order.Status == "closed" { return ErrInvalidDispute } + isCheckoutDispute := order.Status == "pending_checkout_confirm" || order.Status == "pending_checkout_accept" var count int64 if err := tx.Model(&model.Dispute{}). Where("order_id = ? AND status IN ?", order.ID, []string{"open", "processing"}). @@ -57,7 +58,7 @@ func (r *Repository) Create(userID uint64, orderID uint64, req CreateRequest) (* OrderID: order.ID, InitiatorID: userID, TargetUserID: targetID, - Type: req.Type, + Type: disputeType(req.Type, isCheckoutDispute), Status: "open", Description: req.Description, EvidenceURLS: evidence, @@ -65,17 +66,42 @@ func (r *Repository) Create(userID uint64, orderID uint64, req CreateRequest) (* if err := tx.Create(&row).Error; err != nil { return err } - order.Status = "disputing" + if isCheckoutDispute { + now := time.Now() + order.Status = "checkout_disputing" + order.HandoffStatus = "checkout_disputed" + order.SettlementStatus = "disputed" + updates := map[string]any{ + "status": "disputed", + "updated_at": now, + } + if userID == order.RenterID { + updates["renter_rejected_at"] = now + } + if err := tx.Model(&model.OrderCheckout{}). + Where("order_id = ? AND status IN ?", order.ID, []string{"submitted", "countered"}). + Updates(updates).Error; err != nil { + return err + } + } else { + order.Status = "disputing" + } if err := tx.Save(&order).Error; err != nil { return err } disputeID := row.ID + title := "订单进入申诉" + content := "对方已发起申诉,请等待客服仲裁或补充沟通记录。" + if isCheckoutDispute { + title = "订单进入结账争议" + content = "对方已发起结账争议,请等待客服仲裁或补充结账证据。" + } if err := notification.Append(tx, notification.Entry{ UserID: targetID, Type: "dispute", - Title: "订单进入申诉", - Content: "对方已发起申诉,请等待客服仲裁或补充沟通记录。", + Title: title, + Content: content, BizType: "dispute", BizID: &disputeID, }, @@ -99,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) { @@ -122,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) { @@ -177,6 +215,9 @@ func (r *Repository) Arbitrate(adminID uint64, id uint64, req ArbitrateRequest, if req.Result == "order_close" { listing.Status = "offline" account.Status = "offline" + } else if req.Result == "mark_abnormal" { + listing.Status = "abnormal" + account.Status = "abnormal" } else { listing.Status = "published" account.Status = "published" @@ -335,6 +376,8 @@ func buildArbitrationSettlement(order model.RentalOrder, req ArbitrateRequest) ( addRenterRefund(order.DepositAmount-deductAmount, "仲裁退回剩余押金给租客") case "order_close": // Only release frozen funds. No available-balance settlement happens in development mode. + case "mark_abnormal": + // 标记异常只释放冻结账务,后续由客服继续线下复核。 default: return settlement, ErrInvalidDispute } @@ -395,11 +438,20 @@ func arbitrateOrderStatus(result string) string { switch result { case "full_refund", "partial_refund", "order_close": return "closed" + case "mark_abnormal": + return "abnormal" default: return "completed" } } +func disputeType(input string, isCheckoutDispute bool) string { + if isCheckoutDispute { + return "checkout_dispute" + } + return input +} + func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizType string, bizID uint64, meta AuditMeta, detail map[string]any) error { raw, err := json.Marshal(detail) if err != nil { diff --git a/backend/internal/modules/dispute/service.go b/backend/internal/modules/dispute/service.go index 3718737..61621f4 100644 --- a/backend/internal/modules/dispute/service.go +++ b/backend/internal/modules/dispute/service.go @@ -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) { diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 74456ab..8fd9d63 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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 { @@ -215,6 +215,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) diff --git a/frontend/src/api/adminAuth.ts b/frontend/src/api/adminAuth.ts index 133b491..9c8bc9d 100644 --- a/frontend/src/api/adminAuth.ts +++ b/frontend/src/api/adminAuth.ts @@ -56,3 +56,12 @@ export async function logoutAdmin() { const { data } = await apiClient.post>('/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 +} \ No newline at end of file diff --git a/frontend/src/api/adminUsers.ts b/frontend/src/api/adminUsers.ts index 18bec6d..3975837 100644 --- a/frontend/src/api/adminUsers.ts +++ b/frontend/src/api/adminUsers.ts @@ -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 { data: T } -export async function fetchAdminUsers() { - const { data } = await apiClient.get>('/admin/users') - return data.data.items +export async function fetchAdminUsers(page = 1, pageSize = 20) { + const { data } = await apiClient.get>>('/admin/users', { + params: { page, page_size: pageSize }, + }) + return data.data } export async function freezeAdminUser(id: number, reason: string) { diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index e7e24a9..2227d3f 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -50,3 +50,11 @@ export async function updateMe(payload: Pick>('/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>('/auth/refresh', { + refresh_token: refreshToken, + }) + return data.data +} \ No newline at end of file diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index a03edad..5a8eda8 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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 { + const refreshTokenKey = isAdmin ? 'admin_refresh_token' : 'refresh_token' + const refreshToken = localStorage.getItem(refreshTokenKey) + if (!refreshToken) { + throw new Error('no refresh token') + } + const endpoint = isAdmin ? '/api/admin/auth/refresh' : '/api/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 - if (isAdminRequest) { - localStorage.removeItem('admin_access_token') - localStorage.removeItem('admin_refresh_token') - if (!window.location.pathname.startsWith('/admin/login')) { + // 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) } - 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')) { - const redirect = encodeURIComponent(currentPath) - const loginPath = window.location.pathname.startsWith('/m') ? '/m/login' : '/login' - window.location.assign(`${loginPath}?redirect=${redirect}`) + // if already refreshing, queue up + if (isRefreshing) { + return new Promise((resolve) => { + addPendingRequest((newToken: string) => { + originalRequest.headers.Authorization = `Bearer ${newToken}` + resolve(apiClient(originalRequest)) + }) + }) } - return Promise.reject(error) - }, + // attempt refresh + isRefreshing = true + try { + const newToken = await refreshTokenAndRetry(isAdminRequest) + subscribePendingRequests(newToken) + originalRequest._retry = true + originalRequest.headers.Authorization = `Bearer ${newToken}` + return apiClient(originalRequest) + } catch { + // refresh failed, clear tokens and redirect to login + subscribePendingRequests('') // let queued requests fail + if (isAdminRequest) { + clearAdminTokens() + if (!window.location.pathname.startsWith('/admin/login')) { + window.location.assign('/admin/login') + } + } else { + clearUserTokens() + const currentPath = window.location.pathname + window.location.search + if (!currentPath.startsWith('/m/login') && !currentPath.startsWith('/login')) { + const redirect = encodeURIComponent(currentPath) + const loginPath = currentPath.startsWith('/m') ? '/m/login' : '/login' + window.location.assign(`${loginPath}?redirect=${redirect}`) + } + } + return Promise.reject(error) + } finally { + isRefreshing = false + } + } ) diff --git a/frontend/src/api/disputes.ts b/frontend/src/api/disputes.ts index b71df81..8a2b775 100644 --- a/frontend/src/api/disputes.ts +++ b/frontend/src/api/disputes.ts @@ -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>('/disputes') - return data.data.items +export async function fetchDisputes(page = 1, pageSize = 20) { + const { data } = await apiClient.get>>('/disputes', { + params: { page, page_size: pageSize }, + }) + return data.data } -export async function fetchAdminDisputes() { - const { data } = await apiClient.get>('/admin/disputes') - return data.data.items +export async function fetchAdminDisputes(page = 1, pageSize = 20) { + const { data } = await apiClient.get>>('/admin/disputes', { + params: { page, page_size: pageSize }, + }) + return data.data } export async function arbitrateDispute(id: number, payload: { result: string; remark: string; amount?: number }) { diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index c9d7b43..23d96f3 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -61,6 +61,12 @@ const router = createRouter({ component: () => import("@/views/mobile/MobileOrdersView.vue"), meta: { layout: "blank", requiresAuth: true }, }, + { + path: "/m/orders/:id", + name: "mobile-order-detail", + component: () => import("@/views/account/OrderDetailView.vue"), + meta: { layout: "blank", requiresAuth: true }, + }, { path: "/m/seller/listings/create", name: "mobile-seller-listing-create", @@ -92,51 +98,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 +236,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 } }; } } } diff --git a/frontend/src/views/admin/AdminDisputesView.vue b/frontend/src/views/admin/AdminDisputesView.vue index 2825794..8decd85 100644 --- a/frontend/src/views/admin/AdminDisputesView.vue +++ b/frontend/src/views/admin/AdminDisputesView.vue @@ -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(null) const result = ref('release_deposit') const remark = ref('') const amount = ref() +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' @@ -99,7 +110,7 @@ function readError(error: unknown, fallback: string) { @@ -109,20 +120,30 @@ function readError(error: unknown, fallback: string) { - - - - + + - + + +
+ +
+

{{ activeDispute.order_no }} · {{ activeDispute.title }}

@@ -134,6 +155,7 @@ function readError(error: unknown, fallback: string) { + ([]) const activeUser = ref(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) { 刷新
- - + - - - - - - - + - - - - - + +