diff --git a/backend/internal/modules/order/handler_admin.go b/backend/internal/modules/order/handler_admin.go index 0571af7..6d8416f 100644 --- a/backend/internal/modules/order/handler_admin.go +++ b/backend/internal/modules/order/handler_admin.go @@ -248,6 +248,15 @@ func (h *Handler) ListPendingRefund(c *gin.Context) { response.OK(c, gin.H{"items": items}) } +func (h *Handler) PendingRefundCount(c *gin.Context) { + count, err := h.service.PendingRefundCount(c.Request.Context()) + if err != nil { + writeOrderError(c, err) + return + } + response.OK(c, gin.H{"pending_refund_count": count}) +} + func (h *Handler) adminAction(c *gin.Context, fn func(context.Context, uint64, uint64, AdminActionRequest, AuditMeta) error, okData gin.H) { adminID, ok := currentAdminID(c) if !ok { diff --git a/backend/internal/modules/order/refund_review.go b/backend/internal/modules/order/refund_review.go index 62709f8..656171a 100644 --- a/backend/internal/modules/order/refund_review.go +++ b/backend/internal/modules/order/refund_review.go @@ -2,6 +2,8 @@ package order import ( "context" + + "hfb_sys/backend/internal/model" ) // ListPendingRefund 查询所有待客服审核退款的订单。 @@ -26,3 +28,13 @@ func (r *Repository) ListPendingRefund(ctx context.Context) ([]OrderDTO, error) } return items, nil } + +// PendingRefundCount 返回待客服审核退款数量,用于后台导航角标。 +func (r *Repository) PendingRefundCount(ctx context.Context) (int64, error) { + var count int64 + err := r.db.WithContext(ctx). + Model(&model.RentalOrder{}). + Where("refund_status = ?", refundStatusPendingReview). + Count(&count).Error + return count, err +} diff --git a/backend/internal/modules/order/service.go b/backend/internal/modules/order/service.go index 1f8643f..8e7e5a8 100644 --- a/backend/internal/modules/order/service.go +++ b/backend/internal/modules/order/service.go @@ -351,6 +351,13 @@ func (s *Service) ListPendingRefund(ctx context.Context) ([]OrderDTO, error) { return s.repo.ListPendingRefund(ctx) } +func (s *Service) PendingRefundCount(ctx context.Context) (int64, error) { + if s.repo == nil { + return 0, ErrDependencyUnavailable + } + return s.repo.PendingRefundCount(ctx) +} + func (s *Service) FindForUser(ctx context.Context, userID uint64, orderID uint64) (*OrderDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index e2dd0c4..130a5b3 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -620,6 +620,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.POST("/orders/:id/deposit-hold", requirePerm("order:deposit_hold"), orderHandler.AdminHoldDeposit) adminRoutes.POST("/orders/:id/deposit-release", requirePerm("order:deposit_hold"), orderHandler.AdminReleaseDeposit) adminRoutes.GET("/orders/refund-pending", requirePerm("order:close"), orderHandler.ListPendingRefund) + adminRoutes.GET("/orders/refund-pending-count", requirePerm("order:close"), orderHandler.PendingRefundCount) adminRoutes.POST("/orders/:id/dispute", requirePerm("dispute:arbitrate"), disputeHandler.AdminCreateByOrder) // 管理员线下提号(独立于正常订单流程) diff --git a/frontend/src/features/admin/composables/usePendingRefundCount.ts b/frontend/src/features/admin/composables/usePendingRefundCount.ts new file mode 100644 index 0000000..e53ac73 --- /dev/null +++ b/frontend/src/features/admin/composables/usePendingRefundCount.ts @@ -0,0 +1,89 @@ +import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import type { RouteLocationNormalizedLoaded } from 'vue-router' + +import { fetchPendingRefundCount } from '@/features/orders/api/orders' +import { useAdminSessionStore } from '@/stores/adminSession' + +export const pendingRefundCountChangedEvent = 'pending-refund-count-changed' + +export function usePendingRefundCount(route: RouteLocationNormalizedLoaded) { + const adminSession = useAdminSessionStore() + const count = ref(0) + let timer: number | null = null + let requestID = 0 + + const label = computed(() => (count.value > 99 ? '99+' : String(count.value))) + const canReviewRefund = computed( + () => adminSession.isSuperAdmin || adminSession.hasPermission('order:close') + ) + + function stopPolling() { + if (timer === null) return + window.clearInterval(timer) + timer = null + } + + async function refresh() { + if (!canReviewRefund.value) { + count.value = 0 + return + } + const currentID = ++requestID + try { + const nextCount = await fetchPendingRefundCount() + if (currentID === requestID) count.value = nextCount + } catch { + // 角标加载失败时保持当前数字,避免影响后台其他操作。 + } + } + + function startPolling() { + stopPolling() + void refresh() + timer = window.setInterval(refresh, 30_000) + } + + function handleVisibilityChange() { + if (document.hidden) { + stopPolling() + return + } + startPolling() + } + + function handleCountChanged() { + void refresh() + } + + onMounted(() => { + window.addEventListener(pendingRefundCountChangedEvent, handleCountChanged) + document.addEventListener('visibilitychange', handleVisibilityChange) + if (!document.hidden) startPolling() + }) + + onBeforeUnmount(() => { + stopPolling() + window.removeEventListener(pendingRefundCountChangedEvent, handleCountChanged) + document.removeEventListener('visibilitychange', handleVisibilityChange) + }) + + watch(canReviewRefund, canReview => { + if (canReview && !document.hidden) { + startPolling() + return + } + stopPolling() + count.value = 0 + }) + + watch( + () => route.fullPath, + () => void refresh() + ) + + return { + pendingRefundCount: count, + pendingRefundLabel: label, + refreshPendingRefundCount: refresh, + } +} diff --git a/frontend/src/features/admin/views/AdminRefundReviewView.vue b/frontend/src/features/admin/views/AdminRefundReviewView.vue index 6dcdc97..c77efe6 100644 --- a/frontend/src/features/admin/views/AdminRefundReviewView.vue +++ b/frontend/src/features/admin/views/AdminRefundReviewView.vue @@ -14,6 +14,7 @@ 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' +import { pendingRefundCountChangedEvent } from '@/features/admin/composables/usePendingRefundCount' const router = useRouter() const loading = ref(false) @@ -46,6 +47,7 @@ async function handleApprove(row: Order) { submitting.value = true try { await adminApproveRefund(row.id) + window.dispatchEvent(new Event(pendingRefundCountChangedEvent)) ElMessage.success(`退款审核通过,${row.order_no} 已发起退款`) await loadOrders() } catch (error) { @@ -75,6 +77,7 @@ async function handleRejectRestore() { submitting.value = true try { await adminRejectRefund(row.id, 'restore') + window.dispatchEvent(new Event(pendingRefundCountChangedEvent)) ElMessage.success(`退款已驳回,${row.order_no} 已恢复至待交接状态`) rejectVisible.value = false await loadOrders() @@ -100,6 +103,7 @@ async function handleRejectClose() { submitting.value = true try { await adminRejectRefund(row.id, 'close') + window.dispatchEvent(new Event(pendingRefundCountChangedEvent)) ElMessage.success(`退款已驳回,${row.order_no} 已关闭`) rejectVisible.value = false await loadOrders() diff --git a/frontend/src/features/orders/api/orders.ts b/frontend/src/features/orders/api/orders.ts index 375c0e8..47d3ffd 100644 --- a/frontend/src/features/orders/api/orders.ts +++ b/frontend/src/features/orders/api/orders.ts @@ -509,3 +509,11 @@ export async function fetchPendingRefundOrders() { ) return data.data.items } + +export async function fetchPendingRefundCount() { + const { data } = await apiClient.get>( + '/admin/orders/refund-pending-count', + { silent: true } + ) + return data.data.pending_refund_count +} diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index 9c5c1cd..5b6a348 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -36,6 +36,7 @@ import { useRoute, useRouter } from 'vue-router' import { changeAdminPassword, logoutAdmin, updateSupportStatus } from '@/features/admin' import { useAdminNotificationUnreadCount } from '@/features/admin/composables/useAdminNotificationUnreadCount' +import { usePendingRefundCount } from '@/features/admin/composables/usePendingRefundCount' import { useAdminSessionStore } from '@/stores/adminSession' import { adminPath, ADMIN_DASHBOARD_PATH, ADMIN_LOGIN_PATH } from '@/shared/utils/adminPath' import { readError } from '@/shared/utils/error' @@ -303,6 +304,8 @@ const hasNotificationPermission = computed( ) const { unreadCount: adminNotificationUnreadCount, unreadLabel: adminNotificationUnreadLabel } = useAdminNotificationUnreadCount(route) +const { pendingRefundCount, pendingRefundLabel } = usePendingRefundCount(route) +const refundReviewPath = adminPath('orders/refund-review') const statusLabels: Record = { online: '在线', @@ -410,7 +413,15 @@ async function handleForcedPasswordChange() { - + @@ -807,6 +818,40 @@ async function handleForcedPasswordChange() { background-color: #1f2d3d; } +.menu-item-label { + min-width: 0; +} + +.menu-item-badge { + margin-left: auto; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + background: #f43f5e; + color: #ffffff; + font-size: 11px; + font-style: normal; + font-weight: 700; + line-height: 18px; + text-align: center; +} + +:deep(.el-menu--collapse .menu-item-badge) { + position: absolute; + top: 6px; + right: 5px; + min-width: 8px; + width: 8px; + height: 8px; + padding: 0; + border: 2px solid #304156; + border-radius: 50%; + color: transparent; + font-size: 0; + line-height: 0; +} + :global(.admin-menu-popper) { --el-menu-bg-color: #304156; --el-menu-text-color: #bfcbd9;