退款审核功能:待交接取消不再自动退款,改为客服审核后原路退回
- 租客在待交接状态取消订单不再自动退款,改为 refund_status=pending_review - 新增退款审核页面 /admin/orders/refund-review,客服可逐个审核 - 新增 POST /refund/approve 通过退款、POST /refund/reject 驳回退款 - 新增 GET /orders/refund-pending 查询待审核退款订单列表 - 优化状态标签表述:已取消→租客已取消、已关闭→客服已关闭、异常→客服介入 - 新增 refund_status: pending_review 及其前端标签「待客服审核退款」 - AdminOrderDetailView 退款状态改用统一 refundStatusLabel
This commit is contained in:
@@ -2,6 +2,7 @@ package order
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
@@ -356,6 +357,50 @@ func (r *Repository) buildRefundStatusDTO(order *model.RentalOrder) *RefundStatu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminApproveRefund 客服审核通过退款申请,触发渠道原路退款。
|
||||||
|
func (r *Repository) AdminApproveRefund(ctx context.Context, orderID uint64) error {
|
||||||
|
var refund *refundAction
|
||||||
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var order model.RentalOrder
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if order.RefundStatus != refundStatusPendingReview {
|
||||||
|
return errors.New("订单不处于待审核退款状态")
|
||||||
|
}
|
||||||
|
if order.RefundAmountCent <= 0 {
|
||||||
|
return errors.New("退款金额无效")
|
||||||
|
}
|
||||||
|
action, err := r.prepareRefund(&order, order.RefundAmountCent, refundBizCancel, "取消订单原路退款(客服审核通过)")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
refund = action
|
||||||
|
return tx.Save(&order).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.startRefundBestEffort(ctx, refund)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminRejectRefund 客服驳回退款申请,清除退款状态和金额。
|
||||||
|
func (r *Repository) AdminRejectRefund(ctx context.Context, orderID uint64) error {
|
||||||
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var order model.RentalOrder
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if order.RefundStatus != refundStatusPendingReview {
|
||||||
|
return errors.New("订单不处于待审核退款状态")
|
||||||
|
}
|
||||||
|
order.RefundStatus = "none"
|
||||||
|
order.RefundAmountCent = 0
|
||||||
|
return tx.Save(&order).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func isTerminalStatus(status string) bool {
|
func isTerminalStatus(status string) bool {
|
||||||
return status == orderStatusCompleted || status == orderStatusCancelled || status == orderStatusClosed
|
return status == orderStatusCompleted || status == orderStatusCancelled || status == orderStatusClosed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const (
|
|||||||
checkoutStatusDisputed = "disputed"
|
checkoutStatusDisputed = "disputed"
|
||||||
|
|
||||||
refundStatusPending = "pending"
|
refundStatusPending = "pending"
|
||||||
|
refundStatusPendingReview = "pending_review"
|
||||||
refundStatusRefunded = "refunded"
|
refundStatusRefunded = "refunded"
|
||||||
|
|
||||||
listingReviewStatusApproved = "approved"
|
listingReviewStatusApproved = "approved"
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ type OrderDTO struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
HandoffStatus string `json:"handoff_status"`
|
HandoffStatus string `json:"handoff_status"`
|
||||||
SettlementStatus string `json:"settlement_status"`
|
SettlementStatus string `json:"settlement_status"`
|
||||||
|
RefundStatus string `json:"refund_status,omitempty"`
|
||||||
|
RefundAmountCent int64 `json:"refund_amount_cent"`
|
||||||
ActiveDispute *ActiveDisputeDTO `json:"active_dispute,omitempty"`
|
ActiveDispute *ActiveDisputeDTO `json:"active_dispute,omitempty"`
|
||||||
Checkout *CheckoutDTO `json:"checkout,omitempty"`
|
Checkout *CheckoutDTO `json:"checkout,omitempty"`
|
||||||
AdminActions *AdminActionsDTO `json:"admin_actions,omitempty"`
|
AdminActions *AdminActionsDTO `json:"admin_actions,omitempty"`
|
||||||
|
|||||||
@@ -96,6 +96,39 @@ func (h *Handler) AdminRefundStatus(c *gin.Context) {
|
|||||||
response.OK(c, item)
|
response.OK(c, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AdminApproveRefund(c *gin.Context) {
|
||||||
|
orderID, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.AdminApproveRefund(c.Request.Context(), orderID); err != nil {
|
||||||
|
writeOrderError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"approved": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AdminRejectRefund(c *gin.Context) {
|
||||||
|
orderID, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.AdminRejectRefund(c.Request.Context(), orderID); err != nil {
|
||||||
|
writeOrderError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"rejected": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListPendingRefund(c *gin.Context) {
|
||||||
|
items, err := h.service.ListPendingRefund(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeOrderError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) adminAction(c *gin.Context, fn func(context.Context, uint64, uint64, AdminActionRequest, AuditMeta) error, okData gin.H) {
|
func (h *Handler) adminAction(c *gin.Context, fn func(context.Context, uint64, uint64, AdminActionRequest, AuditMeta) error, okData gin.H) {
|
||||||
adminID, ok := currentAdminID(c)
|
adminID, ok := currentAdminID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -227,7 +227,6 @@ func (r *Repository) ConfirmPaidFromChannelTx(tx *gorm.DB, orderID uint64) (uint
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) Cancel(ctx context.Context, userID uint64, orderID uint64) error {
|
func (r *Repository) Cancel(ctx context.Context, userID uint64, orderID uint64) error {
|
||||||
var refund *refundAction
|
|
||||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
var order model.RentalOrder
|
var order model.RentalOrder
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
@@ -251,18 +250,16 @@ func (r *Repository) Cancel(ctx context.Context, userID uint64, orderID uint64)
|
|||||||
orderID := order.ID
|
orderID := order.ID
|
||||||
if beforeStatus == orderStatusPendingHandoff {
|
if beforeStatus == orderStatusPendingHandoff {
|
||||||
totalCent := order.RentAmountCent + order.DepositAmountCent
|
totalCent := order.RentAmountCent + order.DepositAmountCent
|
||||||
action, err := r.prepareRefund(&order, totalCent, refundBizCancel, "取消订单原路退款")
|
order.RefundStatus = refundStatusPendingReview
|
||||||
if err != nil {
|
order.RefundAmountCent = totalCent
|
||||||
return err
|
order.RefundedAt = nil
|
||||||
}
|
|
||||||
refund = action
|
|
||||||
}
|
}
|
||||||
if err := notification.Append(tx,
|
if err := notification.Append(tx,
|
||||||
notification.Entry{
|
notification.Entry{
|
||||||
UserID: order.OwnerID,
|
UserID: order.OwnerID,
|
||||||
Type: "order",
|
Type: "order",
|
||||||
Title: "订单已取消",
|
Title: "订单已取消",
|
||||||
Content: "租客已取消订单,账号已重新释放。",
|
Content: "租客已取消订单,退款待客服审核。",
|
||||||
BizType: "order",
|
BizType: "order",
|
||||||
BizID: &orderID,
|
BizID: &orderID,
|
||||||
},
|
},
|
||||||
@@ -270,7 +267,7 @@ func (r *Repository) Cancel(ctx context.Context, userID uint64, orderID uint64)
|
|||||||
UserID: order.RenterID,
|
UserID: order.RenterID,
|
||||||
Type: "order",
|
Type: "order",
|
||||||
Title: "订单取消成功",
|
Title: "订单取消成功",
|
||||||
Content: "订单已取消,退款将原路退回您的支付账户。",
|
Content: "订单已取消,退款将由客服审核后原路退回。",
|
||||||
BizType: "order",
|
BizType: "order",
|
||||||
BizID: &orderID,
|
BizID: &orderID,
|
||||||
},
|
},
|
||||||
@@ -293,7 +290,6 @@ func (r *Repository) Cancel(ctx context.Context, userID uint64, orderID uint64)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
r.startRefundBestEffort(ctx, refund)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ func (row orderRow) toAdminDTO() OrderDTO {
|
|||||||
Status: row.Status,
|
Status: row.Status,
|
||||||
HandoffStatus: row.HandoffStatus,
|
HandoffStatus: row.HandoffStatus,
|
||||||
SettlementStatus: row.SettlementStatus,
|
SettlementStatus: row.SettlementStatus,
|
||||||
|
RefundStatus: row.RefundStatus,
|
||||||
|
RefundAmountCent: row.RefundAmountCent,
|
||||||
AdminActions: adminActionsForOrder(row.RentalOrder),
|
AdminActions: adminActionsForOrder(row.RentalOrder),
|
||||||
CreatedAt: row.CreatedAt,
|
CreatedAt: row.CreatedAt,
|
||||||
UpdatedAt: row.UpdatedAt,
|
UpdatedAt: row.UpdatedAt,
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package order
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListPendingRefund 查询所有待客服审核退款的订单。
|
||||||
|
func (r *Repository) ListPendingRefund(ctx context.Context) ([]OrderDTO, error) {
|
||||||
|
var rows []orderRow
|
||||||
|
err := r.adminQuery(ctx).
|
||||||
|
Where("o.refund_status = ?", refundStatusPendingReview).
|
||||||
|
Order("o.updated_at ASC, o.id ASC").
|
||||||
|
Limit(200).
|
||||||
|
Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
db := r.db.WithContext(ctx)
|
||||||
|
paymentTimeoutMinutes := pendingPaymentTimeoutMinutes(db)
|
||||||
|
items := make([]OrderDTO, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
dto := row.toAdminDTO()
|
||||||
|
applyPaymentDeadline(&dto, row.RentalOrder, paymentTimeoutMinutes)
|
||||||
|
items = append(items, dto)
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
@@ -226,6 +226,33 @@ func (s *Service) AdminRefundStatus(ctx context.Context, orderID uint64) (*Refun
|
|||||||
return s.repo.AdminRefundStatus(ctx, orderID)
|
return s.repo.AdminRefundStatus(ctx, orderID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) AdminApproveRefund(ctx context.Context, orderID uint64) error {
|
||||||
|
if s.repo == nil {
|
||||||
|
return ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
if orderID == 0 {
|
||||||
|
return ErrOrderCannotComplete
|
||||||
|
}
|
||||||
|
return s.repo.AdminApproveRefund(ctx, orderID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) AdminRejectRefund(ctx context.Context, orderID uint64) error {
|
||||||
|
if s.repo == nil {
|
||||||
|
return ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
if orderID == 0 {
|
||||||
|
return ErrOrderCannotComplete
|
||||||
|
}
|
||||||
|
return s.repo.AdminRejectRefund(ctx, orderID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListPendingRefund(ctx context.Context) ([]OrderDTO, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
return s.repo.ListPendingRefund(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) FindForUser(ctx context.Context, userID uint64, orderID uint64) (*OrderDTO, error) {
|
func (s *Service) FindForUser(ctx context.Context, userID uint64, orderID uint64) (*OrderDTO, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
|
|||||||
@@ -522,6 +522,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
adminRoutes.POST("/orders/:id/reset-handoff", requirePerm("order:mark_abnormal"), orderHandler.AdminResetHandoff)
|
adminRoutes.POST("/orders/:id/reset-handoff", requirePerm("order:mark_abnormal"), orderHandler.AdminResetHandoff)
|
||||||
adminRoutes.POST("/orders/:id/refund", requirePerm("order:close"), orderHandler.AdminRefund)
|
adminRoutes.POST("/orders/:id/refund", requirePerm("order:close"), orderHandler.AdminRefund)
|
||||||
adminRoutes.GET("/orders/:id/refund-status", requirePerm("order:view"), orderHandler.AdminRefundStatus)
|
adminRoutes.GET("/orders/:id/refund-status", requirePerm("order:view"), orderHandler.AdminRefundStatus)
|
||||||
|
adminRoutes.POST("/orders/:id/refund/approve", requirePerm("order:close"), orderHandler.AdminApproveRefund)
|
||||||
|
adminRoutes.POST("/orders/:id/refund/reject", requirePerm("order:close"), orderHandler.AdminRejectRefund)
|
||||||
|
adminRoutes.GET("/orders/refund-pending", requirePerm("order:close"), orderHandler.ListPendingRefund)
|
||||||
adminRoutes.GET("/listings", requirePerm("listing:view"), listingHandler.ListAdmin)
|
adminRoutes.GET("/listings", requirePerm("listing:view"), listingHandler.ListAdmin)
|
||||||
adminRoutes.GET("/listings/pending", requirePerm("listing:approve"), listingHandler.ListPendingReview)
|
adminRoutes.GET("/listings/pending", requirePerm("listing:approve"), listingHandler.ListPendingReview)
|
||||||
adminRoutes.GET("/listings/:id", requirePerm("listing:view"), listingHandler.FindAdmin)
|
adminRoutes.GET("/listings/:id", requirePerm("listing:view"), listingHandler.FindAdmin)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
disputeStatusLabel,
|
disputeStatusLabel,
|
||||||
handoffStatusLabel,
|
handoffStatusLabel,
|
||||||
orderStatusLabel,
|
orderStatusLabel,
|
||||||
|
refundStatusLabel,
|
||||||
settlementStatusLabel,
|
settlementStatusLabel,
|
||||||
} from '@/shared/utils/statusLabels'
|
} from '@/shared/utils/statusLabels'
|
||||||
import { formatDateTime } from '@/shared/utils/time'
|
import { formatDateTime } from '@/shared/utils/time'
|
||||||
@@ -293,16 +294,6 @@ async function confirmRefund() {
|
|||||||
await handleRefund()
|
await handleRefund()
|
||||||
}
|
}
|
||||||
|
|
||||||
function refundStatusLabel(status: string) {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
pending: '待退款',
|
|
||||||
refunding: '退款中',
|
|
||||||
refunded: '已退款',
|
|
||||||
failed: '退款失败',
|
|
||||||
}
|
|
||||||
return map[status] || status || '未退款'
|
|
||||||
}
|
|
||||||
|
|
||||||
function paymentStatusLabel(status: string) {
|
function paymentStatusLabel(status: string) {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
created: '已创建',
|
created: '已创建',
|
||||||
|
|||||||
@@ -35,10 +35,10 @@ const orderStatusOptions = [
|
|||||||
{ label: '待租客确认修正', value: 'pending_checkout_accept' },
|
{ label: '待租客确认修正', value: 'pending_checkout_accept' },
|
||||||
{ label: '结账争议中', value: 'checkout_disputing' },
|
{ label: '结账争议中', value: 'checkout_disputing' },
|
||||||
{ label: '申诉中', value: 'disputing' },
|
{ label: '申诉中', value: 'disputing' },
|
||||||
{ label: '异常', value: 'abnormal' },
|
{ label: '客服介入', value: 'abnormal' },
|
||||||
{ label: '已完成', value: 'completed' },
|
{ label: '已完成', value: 'completed' },
|
||||||
{ label: '已关闭', value: 'closed' },
|
{ label: '客服已关闭', value: 'closed' },
|
||||||
{ label: '已取消', value: 'cancelled' },
|
{ label: '租客已取消', value: 'cancelled' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const handoffStatusOptions = [
|
const handoffStatusOptions = [
|
||||||
@@ -60,8 +60,8 @@ const settlementStatusOptions = [
|
|||||||
{ label: '冻结中', value: 'frozen' },
|
{ label: '冻结中', value: 'frozen' },
|
||||||
{ label: '已结算', value: 'settled' },
|
{ label: '已结算', value: 'settled' },
|
||||||
{ label: '已退款', value: 'refunded' },
|
{ label: '已退款', value: 'refunded' },
|
||||||
{ label: '已取消', value: 'cancelled' },
|
{ label: '结算已取消', value: 'cancelled' },
|
||||||
{ label: '已关闭', value: 'closed' },
|
{ label: '结算已关闭', value: 'closed' },
|
||||||
{ label: '争议中', value: 'disputed' },
|
{ label: '争议中', value: 'disputed' },
|
||||||
{ label: '已仲裁', value: 'arbitrated' },
|
{ label: '已仲裁', value: 'arbitrated' },
|
||||||
] as const
|
] as const
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Check, Close, Refresh, View } from '@element-plus/icons-vue'
|
||||||
|
import {
|
||||||
|
adminApproveRefund,
|
||||||
|
adminRejectRefund,
|
||||||
|
fetchPendingRefundOrders,
|
||||||
|
type Order,
|
||||||
|
} from '@/features/orders/api/orders'
|
||||||
|
import { formatDateTime } from '@/shared/utils/time'
|
||||||
|
import { formatCentWithSymbol } from '@/shared/utils/money'
|
||||||
|
import { orderStatusLabel, handoffStatusLabel } from '@/shared/utils/statusLabels'
|
||||||
|
import { readError } from '@/shared/utils/error'
|
||||||
|
import { adminPath } from '@/shared/utils/adminPath'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const loading = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const orders = ref<Order[]>([])
|
||||||
|
|
||||||
|
async function loadOrders() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
orders.value = await fetchPendingRefundOrders()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '加载退款审核列表失败'))
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleApprove(row: Order) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确认通过「${row.order_no}」的退款申请?通过后将原路退回 ${formatCentWithSymbol(row.refund_amount_cent || 0)}。`,
|
||||||
|
'退款审核通过确认',
|
||||||
|
{ confirmButtonText: '通过退款', cancelButtonText: '取消', type: 'warning' }
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await adminApproveRefund(row.id)
|
||||||
|
ElMessage.success(`退款审核通过,${row.order_no} 已发起退款`)
|
||||||
|
await loadOrders()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '退款审核失败'))
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReject(row: Order) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确认驳回「${row.order_no}」的退款申请?驳回后不再退款。`,
|
||||||
|
'退款审核驳回确认',
|
||||||
|
{ confirmButtonText: '驳回退款', cancelButtonText: '取消', type: 'warning' }
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await adminRejectRefund(row.id)
|
||||||
|
ElMessage.success(`退款审核已驳回,${row.order_no} 不再退款`)
|
||||||
|
await loadOrders()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '驳回退款失败'))
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(order: Order) {
|
||||||
|
router.push(adminPath(`orders/${order.id}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadOrders)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="refund-review-page">
|
||||||
|
<div class="page-toolbar">
|
||||||
|
<h2>退款审核</h2>
|
||||||
|
<el-button :icon="Refresh" :loading="loading" @click="loadOrders">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-loading="loading" class="review-list">
|
||||||
|
<div v-if="!loading && orders.length === 0" class="empty-state">
|
||||||
|
<el-empty description="暂无待审核退款" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="review-cards">
|
||||||
|
<div
|
||||||
|
v-for="order in orders"
|
||||||
|
:key="order.id"
|
||||||
|
class="review-card"
|
||||||
|
>
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="order-info">
|
||||||
|
<span class="order-no" @click="openDetail(order)">{{ order.order_no }}</span>
|
||||||
|
<el-tag size="small" type="warning">待客服审核退款</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="order-time">{{ formatDateTime(order.updated_at) }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">商品</span>
|
||||||
|
<span class="info-value">{{ order.title || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">订单状态</span>
|
||||||
|
<span class="info-value">{{ orderStatusLabel(order.status) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">交接状态</span>
|
||||||
|
<span class="info-value">{{ handoffStatusLabel(order.handoff_status) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">号主</span>
|
||||||
|
<span class="info-value">{{ order.owner_phone || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">租客</span>
|
||||||
|
<span class="info-value">{{ order.renter_phone || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">订单总额</span>
|
||||||
|
<span class="info-value"
|
||||||
|
>{{ formatCentWithSymbol((order.rent_amount_cent || 0) + order.deposit_amount_cent) }}(租金{{
|
||||||
|
formatCentWithSymbol(order.rent_amount_cent || 0)
|
||||||
|
}}
|
||||||
|
/ 押金{{ formatCentWithSymbol(order.deposit_amount_cent) }})</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">退款金额</span>
|
||||||
|
<span class="info-value refund-amount">{{ formatCentWithSymbol(order.refund_amount_cent || 0) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions">
|
||||||
|
<el-button
|
||||||
|
:icon="View"
|
||||||
|
size="small"
|
||||||
|
@click="openDetail(order)"
|
||||||
|
>
|
||||||
|
查看详情
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
:icon="Check"
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:loading="submitting"
|
||||||
|
@click="handleApprove(order)"
|
||||||
|
>
|
||||||
|
通过退款
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
:icon="Close"
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
:loading="submitting"
|
||||||
|
@click="handleReject(order)"
|
||||||
|
>
|
||||||
|
驳回退款
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.refund-review-page {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-toolbar h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 60px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-card {
|
||||||
|
border: 1px solid var(--el-border-color-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-no {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-no:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px 24px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refund-amount {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -34,6 +34,8 @@ export interface Order {
|
|||||||
status: OrderStatus
|
status: OrderStatus
|
||||||
handoff_status: HandoffStatus
|
handoff_status: HandoffStatus
|
||||||
settlement_status: SettlementStatus
|
settlement_status: SettlementStatus
|
||||||
|
refund_status?: string
|
||||||
|
refund_amount_cent?: number
|
||||||
active_dispute?: ActiveDispute
|
active_dispute?: ActiveDispute
|
||||||
checkout?: Checkout
|
checkout?: Checkout
|
||||||
admin_actions?: AdminActions
|
admin_actions?: AdminActions
|
||||||
@@ -364,3 +366,24 @@ export async function adminRefundOrder(id: number) {
|
|||||||
const { data } = await apiClient.post<ApiResponse<RefundStatus>>(`/admin/orders/${id}/refund`)
|
const { data } = await apiClient.post<ApiResponse<RefundStatus>>(`/admin/orders/${id}/refund`)
|
||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function adminApproveRefund(id: number) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<{ approved: boolean }>>(
|
||||||
|
`/admin/orders/${id}/refund/approve`
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminRejectRefund(id: number) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<{ rejected: boolean }>>(
|
||||||
|
`/admin/orders/${id}/refund/reject`
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPendingRefundOrders() {
|
||||||
|
const { data } = await apiClient.get<ApiResponse<{ items: Order[] }>>(
|
||||||
|
'/admin/orders/refund-pending'
|
||||||
|
)
|
||||||
|
return data.data.items
|
||||||
|
}
|
||||||
|
|||||||
@@ -83,6 +83,12 @@ const allNavGroups: NavGroup[] = [
|
|||||||
children: [
|
children: [
|
||||||
{ label: '用户管理', to: adminPath('users'), icon: User, permission: 'user:view' },
|
{ label: '用户管理', to: adminPath('users'), icon: User, permission: 'user:view' },
|
||||||
{ label: '订单管理', to: adminPath('orders'), icon: Tickets, permission: 'order:view' },
|
{ label: '订单管理', to: adminPath('orders'), icon: Tickets, permission: 'order:view' },
|
||||||
|
{
|
||||||
|
label: '退款审核',
|
||||||
|
to: adminPath('orders/refund-review'),
|
||||||
|
icon: Money,
|
||||||
|
permission: 'order:close',
|
||||||
|
},
|
||||||
{ label: '商品管理', to: adminPath('listings'), icon: Shop, permission: 'listing:view' },
|
{ label: '商品管理', to: adminPath('listings'), icon: Shop, permission: 'listing:view' },
|
||||||
{
|
{
|
||||||
label: '商品审核',
|
label: '商品审核',
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ export const adminRoutes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/features/admin/views/AdminOrdersView.vue'),
|
component: () => import('@/features/admin/views/AdminOrdersView.vue'),
|
||||||
meta: adminMeta,
|
meta: adminMeta,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: adminPath('orders/refund-review'),
|
||||||
|
name: 'admin-refund-review',
|
||||||
|
component: () => import('@/features/admin/views/AdminRefundReviewView.vue'),
|
||||||
|
meta: adminMeta,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: adminPath('orders/:id'),
|
path: adminPath('orders/:id'),
|
||||||
name: 'admin-order-detail',
|
name: 'admin-order-detail',
|
||||||
|
|||||||
@@ -40,29 +40,29 @@ const orderStatusMap: Record<OrderStatus, string> = {
|
|||||||
pending_checkout_accept: '待租客确认修正',
|
pending_checkout_accept: '待租客确认修正',
|
||||||
checkout_disputing: '结账争议中',
|
checkout_disputing: '结账争议中',
|
||||||
completed: '已完成',
|
completed: '已完成',
|
||||||
cancelled: '已取消',
|
cancelled: '租客已取消',
|
||||||
closed: '已关闭',
|
closed: '客服已关闭',
|
||||||
disputing: '申诉中',
|
disputing: '申诉中',
|
||||||
abnormal: '异常',
|
abnormal: '客服介入',
|
||||||
}
|
}
|
||||||
|
|
||||||
const handoffStatusMap: Record<HandoffStatus, string> = {
|
const handoffStatusMap: Record<HandoffStatus, string> = {
|
||||||
pending_owner: '待号主交接',
|
pending_owner: '待号主交接',
|
||||||
pending_renter_confirm: '待租客确认',
|
pending_renter_confirm: '待租客确认',
|
||||||
received: '已确认收号',
|
received: '租客已收号',
|
||||||
pending_owner_return_confirm: '待号主确认结账',
|
pending_owner_return_confirm: '待号主确认结账',
|
||||||
pending_owner_checkout: '待号主确认结账',
|
pending_owner_checkout: '待号主确认结账',
|
||||||
pending_renter_checkout: '待租客确认修正',
|
pending_renter_checkout: '待租客确认修正',
|
||||||
checkout_disputed: '结账争议中',
|
checkout_disputed: '结账争议中',
|
||||||
returned: '已归还',
|
returned: '已归还',
|
||||||
cancelled: '已取消',
|
cancelled: '租客已取消',
|
||||||
owner_timeout: '号主交接超时',
|
owner_timeout: '号主交接超时',
|
||||||
renter_confirm_timeout: '租客确认超时',
|
renter_confirm_timeout: '租客确认超时',
|
||||||
return_overdue: '归还逾期',
|
return_overdue: '归还逾期',
|
||||||
owner_return_confirm_timeout: '号主确认结账超时',
|
owner_return_confirm_timeout: '号主确认结账超时',
|
||||||
owner_checkout_confirm_timeout: '号主确认结账超时',
|
owner_checkout_confirm_timeout: '号主确认结账超时',
|
||||||
admin_closed: '客服关闭',
|
admin_closed: '客服已关闭',
|
||||||
admin_abnormal: '客服标记异常',
|
admin_abnormal: '客服介入',
|
||||||
arbitrated: '已仲裁',
|
arbitrated: '已仲裁',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,12 +72,21 @@ const settlementStatusMap: Record<SettlementStatus, string> = {
|
|||||||
frozen: '冻结中',
|
frozen: '冻结中',
|
||||||
settled: '已结算',
|
settled: '已结算',
|
||||||
refunded: '已退款',
|
refunded: '已退款',
|
||||||
cancelled: '已取消',
|
cancelled: '结算已取消',
|
||||||
closed: '已关闭',
|
closed: '结算已关闭',
|
||||||
disputed: '争议中',
|
disputed: '争议中',
|
||||||
arbitrated: '已仲裁',
|
arbitrated: '已仲裁',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refundStatusMap: Record<string, string> = {
|
||||||
|
none: '未退款',
|
||||||
|
pending_review: '待客服审核退款',
|
||||||
|
pending: '待退款',
|
||||||
|
refunding: '退款中',
|
||||||
|
refunded: '已退款',
|
||||||
|
failed: '退款失败',
|
||||||
|
}
|
||||||
|
|
||||||
const realnameStatusMap: Partial<Record<RealnameStatusValue, string>> = {
|
const realnameStatusMap: Partial<Record<RealnameStatusValue, string>> = {
|
||||||
unverified: '未认证',
|
unverified: '未认证',
|
||||||
pending: '认证中',
|
pending: '认证中',
|
||||||
@@ -148,6 +157,10 @@ export function settlementStatusLabel(status: string) {
|
|||||||
return readLabel(settlementStatusMap, status)
|
return readLabel(settlementStatusMap, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function refundStatusLabel(status: string) {
|
||||||
|
return readLabel(refundStatusMap, status)
|
||||||
|
}
|
||||||
|
|
||||||
export function realnameStatusLabel(status: string) {
|
export function realnameStatusLabel(status: string) {
|
||||||
return readLabel(realnameStatusMap, status)
|
return readLabel(realnameStatusMap, status)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user