From 2dfa2611fd908fa30d2228c97e5d6c500f19bc03 Mon Sep 17 00:00:00 2001 From: yml Date: Tue, 9 Jun 2026 00:13:03 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=B4=A2=E5=8A=A1=E4=BB=AA?= =?UTF-8?q?=E8=A1=A8=E7=9B=98=E5=B9=B6=E7=BB=9F=E4=B8=80=E9=87=91=E9=A2=9D?= =?UTF-8?q?=E5=88=B0=E8=A7=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/modules/adminfinance/dto.go | 96 +++++ .../internal/modules/adminfinance/handler.go | 127 ++++++ .../modules/adminfinance/repository.go | 380 ++++++++++++++++++ .../internal/modules/adminfinance/service.go | 36 ++ backend/internal/router/router.go | 9 + frontend/components.d.ts | 1 + .../src/features/admin/api/adminFinance.ts | 116 ++++++ .../components/WithdrawalDetailDialog.vue | 9 +- frontend/src/features/admin/index.ts | 1 + .../admin/views/AdminFinanceDashboardView.vue | 189 +++++++++ .../admin/views/AdminFinanceDetailsView.vue | 252 ++++++++++++ .../admin/views/AdminListingDetailView.vue | 3 +- .../admin/views/AdminListingReviewView.vue | 3 +- .../admin/views/AdminOrderDetailView.vue | 7 +- .../admin/views/AdminPaymentConfigsView.vue | 3 +- .../admin/views/AdminPaymentsView.vue | 3 +- .../features/admin/views/AdminUsersView.vue | 3 +- .../admin/views/AdminWalletLedgerView.vue | 3 +- .../admin/views/AdminWithdrawalsView.vue | 3 +- .../features/auth/views/MobileProfileView.vue | 5 +- .../listings/components/ListingCard.vue | 5 +- .../listings/views/ListingDetailView.vue | 12 +- .../features/listings/views/ListingsView.vue | 5 +- .../listings/views/MobileHomeView.vue | 5 +- .../views/MobileListingDetailView.vue | 10 +- .../orders/views/MobileOrderDetailView.vue | 5 +- .../orders/views/MobileOrdersView.vue | 3 +- .../features/orders/views/OrderDetailView.vue | 7 +- .../src/features/orders/views/OrdersView.vue | 3 +- .../views/MobileSellerListingCreateView.vue | 9 +- .../seller/views/SellerListingCreateView.vue | 19 +- .../seller/views/SellerListingsView.vue | 5 +- .../src/features/wallet/views/WalletView.vue | 2 +- .../features/wallet/views/WithdrawalView.vue | 13 +- frontend/src/layouts/AdminLayout.vue | 12 + frontend/src/router/adminRoutes.ts | 12 + frontend/src/shared/composables/useMoney.ts | 4 +- frontend/src/shared/utils/money.ts | 2 +- frontend/src/utils/listingDisplay.ts | 8 +- frontend/src/utils/pricing.ts | 4 +- 40 files changed, 1324 insertions(+), 70 deletions(-) create mode 100644 backend/internal/modules/adminfinance/dto.go create mode 100644 backend/internal/modules/adminfinance/handler.go create mode 100644 backend/internal/modules/adminfinance/repository.go create mode 100644 backend/internal/modules/adminfinance/service.go create mode 100644 frontend/src/features/admin/api/adminFinance.ts create mode 100644 frontend/src/features/admin/views/AdminFinanceDashboardView.vue create mode 100644 frontend/src/features/admin/views/AdminFinanceDetailsView.vue diff --git a/backend/internal/modules/adminfinance/dto.go b/backend/internal/modules/adminfinance/dto.go new file mode 100644 index 0000000..3ea79b0 --- /dev/null +++ b/backend/internal/modules/adminfinance/dto.go @@ -0,0 +1,96 @@ +package adminfinance + +import "time" + +type DashboardQuery struct { + StartDate time.Time + EndDate time.Time +} + +type DetailQuery struct { + OrderNo string + UserID uint64 + OrderStatus string + SettlementStatus string + DateType string + StartDate time.Time + EndDate time.Time + Page int + PageSize int +} + +type DashboardDTO struct { + Summary FinanceSummaryDTO `json:"summary"` + DailyItems []FinanceDailyDTO `json:"daily_items"` + GeneratedAt time.Time `json:"generated_at"` +} + +type FinanceSummaryDTO struct { + TotalFlowAmountCent int64 `json:"total_flow_amount_cent"` + TotalRefundAmountCent int64 `json:"total_refund_amount_cent"` + PendingRefundAmountCent int64 `json:"pending_refund_amount_cent"` + ChannelNetAmountCent int64 `json:"channel_net_amount_cent"` + PlatformIncomeAmount float64 `json:"platform_income_amount"` + OwnerShouldIncomeAmount float64 `json:"owner_should_income_amount"` + OwnerWalletIncomeAmount float64 `json:"owner_wallet_income_amount"` + SettlementDiffAmount float64 `json:"settlement_diff_amount"` + SuccessfulPayCount int64 `json:"successful_pay_count"` + SuccessfulRefundCount int64 `json:"successful_refund_count"` + PendingRefundCount int64 `json:"pending_refund_count"` + SettledOrderCount int64 `json:"settled_order_count"` + FinancialExceptionCount int64 `json:"financial_exception_count"` +} + +type FinanceDailyDTO struct { + Date string `json:"date"` + TotalFlowAmountCent int64 `json:"total_flow_amount_cent"` + TotalRefundAmountCent int64 `json:"total_refund_amount_cent"` + PendingRefundAmountCent int64 `json:"pending_refund_amount_cent"` + ChannelNetAmountCent int64 `json:"channel_net_amount_cent"` + PlatformIncomeAmount float64 `json:"platform_income_amount"` + OwnerShouldIncomeAmount float64 `json:"owner_should_income_amount"` + OwnerWalletIncomeAmount float64 `json:"owner_wallet_income_amount"` + SettlementDiffAmount float64 `json:"settlement_diff_amount"` + SuccessfulPayCount int64 `json:"successful_pay_count"` + SuccessfulRefundCount int64 `json:"successful_refund_count"` + PendingRefundCount int64 `json:"pending_refund_count"` + SettledOrderCount int64 `json:"settled_order_count"` +} + +type PaginatedResult struct { + Items interface{} `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type FinanceDetailDTO struct { + OrderID uint64 `json:"order_id"` + OrderNo string `json:"order_no"` + OrderStatus string `json:"order_status"` + SettlementStatus string `json:"settlement_status"` + RefundStatus string `json:"refund_status"` + RenterID uint64 `json:"renter_id"` + RenterPhone string `json:"renter_phone"` + RenterNickname string `json:"renter_nickname"` + OwnerID uint64 `json:"owner_id"` + OwnerPhone string `json:"owner_phone"` + OwnerNickname string `json:"owner_nickname"` + OrderRentAmount float64 `json:"order_rent_amount"` + OrderDepositAmount float64 `json:"order_deposit_amount"` + CheckoutRentAmount float64 `json:"checkout_rent_amount"` + CheckoutRenterRefund float64 `json:"checkout_renter_refund"` + CheckoutOwnerIncome float64 `json:"checkout_owner_income"` + CheckoutPlatformFee float64 `json:"checkout_platform_fee"` + OwnerWalletIncomeAmount float64 `json:"owner_wallet_income_amount"` + PaidAmountCent int64 `json:"paid_amount_cent"` + RefundedAmountCent int64 `json:"refunded_amount_cent"` + RefundingAmountCent int64 `json:"refunding_amount_cent"` + FailedRefundAmountCent int64 `json:"failed_refund_amount_cent"` + ChannelNetAmountCent int64 `json:"channel_net_amount_cent"` + PlatformNetAmount float64 `json:"platform_net_amount"` + SettlementDiffAmount float64 `json:"settlement_diff_amount"` + FinanceStatus string `json:"finance_status"` + CreatedAt time.Time `json:"created_at"` + SettledAt *time.Time `json:"settled_at,omitempty"` +} diff --git a/backend/internal/modules/adminfinance/handler.go b/backend/internal/modules/adminfinance/handler.go new file mode 100644 index 0000000..673ad77 --- /dev/null +++ b/backend/internal/modules/adminfinance/handler.go @@ -0,0 +1,127 @@ +package adminfinance + +import ( + "errors" + "net/http" + "strconv" + "time" + + "hfb_sys/backend/internal/timeutil" + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +func (h *Handler) Dashboard(c *gin.Context) { + query, ok := parseDashboardQuery(c) + if !ok { + return + } + item, err := h.service.Dashboard(query) + if err != nil { + writeFinanceError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) Details(c *gin.Context) { + query, ok := parseDetailQuery(c) + if !ok { + return + } + result, err := h.service.Details(query) + if err != nil { + writeFinanceError(c, err) + return + } + response.OK(c, result) +} + +func parseDashboardQuery(c *gin.Context) (DashboardQuery, bool) { + start, end, ok := parseDateRange(c, 6) + if !ok { + return DashboardQuery{}, false + } + return DashboardQuery{StartDate: start, EndDate: end}, true +} + +func parseDetailQuery(c *gin.Context) (DetailQuery, bool) { + start, end, ok := parseDateRange(c, 29) + if !ok { + return DetailQuery{}, false + } + query := DetailQuery{ + OrderNo: c.Query("order_no"), + OrderStatus: c.Query("order_status"), + SettlementStatus: c.Query("settlement_status"), + DateType: c.DefaultQuery("date_type", "settled"), + StartDate: start, + EndDate: end, + } + if query.DateType != "created" && query.DateType != "settled" { + response.BadRequest(c, "日期类型不正确") + return query, false + } + if raw := c.Query("user_id"); raw != "" { + value, err := strconv.ParseUint(raw, 10, 64) + if err != nil || value == 0 { + response.BadRequest(c, "用户 ID 不正确") + return query, false + } + query.UserID = value + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + query.Page = page + query.PageSize = pageSize + return query, true +} + +func parseDateRange(c *gin.Context, defaultLookbackDays int) (time.Time, time.Time, bool) { + loc := timeutil.ShanghaiLocation() + today := timeutil.ShanghaiNow() + defaultStart := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, -defaultLookbackDays) + defaultEnd := time.Date(today.Year(), today.Month(), today.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), loc) + + start := defaultStart + end := defaultEnd + if raw := c.Query("start_date"); raw != "" { + parsed, err := time.ParseInLocation("2006-01-02", raw, loc) + if err != nil { + response.BadRequest(c, "开始日期不正确") + return start, end, false + } + start = parsed + } + if raw := c.Query("end_date"); raw != "" { + parsed, err := time.ParseInLocation("2006-01-02", raw, loc) + if err != nil { + response.BadRequest(c, "结束日期不正确") + return start, end, false + } + end = parsed.AddDate(0, 0, 1).Add(-time.Nanosecond) + } + if start.After(end) { + response.BadRequest(c, "开始日期不能晚于结束日期") + return start, end, false + } + return start, end, true +} + +func writeFinanceError(c *gin.Context, err error) { + switch { + case errors.Is(err, ErrDependencyUnavailable): + response.ServiceUnavailable(c, "数据库未连接") + default: + response.Error(c, http.StatusInternalServerError, "finance_error", "财务数据暂时不可用") + } +} diff --git a/backend/internal/modules/adminfinance/repository.go b/backend/internal/modules/adminfinance/repository.go new file mode 100644 index 0000000..69677c3 --- /dev/null +++ b/backend/internal/modules/adminfinance/repository.go @@ -0,0 +1,380 @@ +package adminfinance + +import ( + "math" + "time" + + "hfb_sys/backend/internal/timeutil" + "hfb_sys/backend/pkg/money" + + "gorm.io/gorm" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) Dashboard(query DashboardQuery) (*DashboardDTO, error) { + dailyItems, err := r.dailyItems(query) + if err != nil { + return nil, err + } + summary, err := r.summary(query) + if err != nil { + return nil, err + } + return &DashboardDTO{ + Summary: *summary, + DailyItems: dailyItems, + GeneratedAt: timeutil.ShanghaiNow(), + }, nil +} + +func (r *Repository) Details(query DetailQuery) (*PaginatedResult, error) { + db := r.financeDetailBaseQuery(query) + var total int64 + countDB := r.db.Table("(?) AS finance_rows", db) + if err := countDB.Count(&total).Error; err != nil { + return nil, err + } + + offset := (query.Page - 1) * query.PageSize + rows := make([]financeDetailRow, 0, query.PageSize) + if err := db.Order("ro.id DESC").Offset(offset).Limit(query.PageSize).Scan(&rows).Error; err != nil { + return nil, err + } + items := make([]FinanceDetailDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, row.toDTO()) + } + return &PaginatedResult{Items: items, Total: total, Page: query.Page, PageSize: query.PageSize}, nil +} + +func (r *Repository) summary(query DashboardQuery) (*FinanceSummaryDTO, error) { + var payment paymentSummaryRow + if err := r.db.Table("payment_orders"). + Select(`COALESCE(SUM(CASE WHEN biz_type IN ('order_pay', 'wallet_recharge') AND status = 'paid' THEN amount_cent ELSE 0 END), 0) AS total_flow_amount_cent, + COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'refunded' THEN amount_cent ELSE 0 END), 0) AS total_refund_amount_cent, + COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'refunding' THEN amount_cent ELSE 0 END), 0) AS pending_refund_amount_cent, + COALESCE(SUM(CASE WHEN biz_type IN ('order_pay', 'wallet_recharge') AND status = 'paid' THEN 1 ELSE 0 END), 0) AS successful_pay_count, + COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'refunded' THEN 1 ELSE 0 END), 0) AS successful_refund_count, + COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'refunding' THEN 1 ELSE 0 END), 0) AS pending_refund_count`, + refundBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes()). + Where("created_at >= ? AND created_at <= ?", query.StartDate, query.EndDate). + Scan(&payment).Error; err != nil { + return nil, err + } + + var settlement settlementSummaryRow + if err := r.db.Table("rental_orders AS ro"). + Select(`COALESCE(SUM(oc.platform_fee), 0) AS platform_income_amount, + COALESCE(SUM(oc.owner_income_amount), 0) AS owner_should_income_amount, + COALESCE(SUM(COALESCE(w.owner_wallet_income_amount, 0)), 0) AS owner_wallet_income_amount, + COUNT(ro.id) AS settled_order_count`). + Joins("JOIN order_checkouts AS oc ON oc.order_id = ro.id AND oc.status = 'accepted'"). + Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeSubquery(r.db)). + Where("ro.settled_at >= ? AND ro.settled_at <= ?", query.StartDate, query.EndDate). + Scan(&settlement).Error; err != nil { + return nil, err + } + + var exceptionCount int64 + if err := r.db.Table("(?) AS d", r.financeDetailBaseQuery(DetailQuery{ + DateType: "settled", + StartDate: query.StartDate, + EndDate: query.EndDate, + })). + Where("finance_status <> ?", "normal"). + Count(&exceptionCount).Error; err != nil { + return nil, err + } + + return &FinanceSummaryDTO{ + TotalFlowAmountCent: payment.TotalFlowAmountCent, + TotalRefundAmountCent: payment.TotalRefundAmountCent, + PendingRefundAmountCent: payment.PendingRefundAmountCent, + ChannelNetAmountCent: payment.TotalFlowAmountCent - payment.TotalRefundAmountCent, + PlatformIncomeAmount: money.Round(settlement.PlatformIncomeAmount), + OwnerShouldIncomeAmount: money.Round(settlement.OwnerShouldIncomeAmount), + OwnerWalletIncomeAmount: money.Round(settlement.OwnerWalletIncomeAmount), + SettlementDiffAmount: money.Round(settlement.OwnerShouldIncomeAmount - settlement.OwnerWalletIncomeAmount), + SuccessfulPayCount: payment.SuccessfulPayCount, + SuccessfulRefundCount: payment.SuccessfulRefundCount, + PendingRefundCount: payment.PendingRefundCount, + SettledOrderCount: settlement.SettledOrderCount, + FinancialExceptionCount: exceptionCount, + }, nil +} + +func (r *Repository) dailyItems(query DashboardQuery) ([]FinanceDailyDTO, error) { + payments := make([]dailyPaymentRow, 0) + if err := r.db.Table("payment_orders"). + Select(`DATE(created_at) AS date, + COALESCE(SUM(CASE WHEN biz_type IN ('order_pay', 'wallet_recharge') AND status = 'paid' THEN amount_cent ELSE 0 END), 0) AS total_flow_amount_cent, + COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'refunded' THEN amount_cent ELSE 0 END), 0) AS total_refund_amount_cent, + COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'refunding' THEN amount_cent ELSE 0 END), 0) AS pending_refund_amount_cent, + COALESCE(SUM(CASE WHEN biz_type IN ('order_pay', 'wallet_recharge') AND status = 'paid' THEN 1 ELSE 0 END), 0) AS successful_pay_count, + COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'refunded' THEN 1 ELSE 0 END), 0) AS successful_refund_count, + COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'refunding' THEN 1 ELSE 0 END), 0) AS pending_refund_count`, + refundBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes()). + Where("created_at >= ? AND created_at <= ?", query.StartDate, query.EndDate). + Group("DATE(created_at)"). + Scan(&payments).Error; err != nil { + return nil, err + } + + settlements := make([]dailySettlementRow, 0) + if err := r.db.Table("rental_orders AS ro"). + Select(`DATE(ro.settled_at) AS date, + COALESCE(SUM(oc.platform_fee), 0) AS platform_income_amount, + COALESCE(SUM(oc.owner_income_amount), 0) AS owner_should_income_amount, + COALESCE(SUM(COALESCE(w.owner_wallet_income_amount, 0)), 0) AS owner_wallet_income_amount, + COUNT(ro.id) AS settled_order_count`). + Joins("JOIN order_checkouts AS oc ON oc.order_id = ro.id AND oc.status = 'accepted'"). + Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeSubquery(r.db)). + Where("ro.settled_at >= ? AND ro.settled_at <= ?", query.StartDate, query.EndDate). + Group("DATE(ro.settled_at)"). + Scan(&settlements).Error; err != nil { + return nil, err + } + + itemsByDate := make(map[string]FinanceDailyDTO) + for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) { + date := day.Format("2006-01-02") + itemsByDate[date] = FinanceDailyDTO{Date: date} + } + for _, row := range payments { + item := itemsByDate[row.Date] + item.Date = row.Date + item.TotalFlowAmountCent = row.TotalFlowAmountCent + item.TotalRefundAmountCent = row.TotalRefundAmountCent + item.PendingRefundAmountCent = row.PendingRefundAmountCent + item.ChannelNetAmountCent = row.TotalFlowAmountCent - row.TotalRefundAmountCent + item.SuccessfulPayCount = row.SuccessfulPayCount + item.SuccessfulRefundCount = row.SuccessfulRefundCount + item.PendingRefundCount = row.PendingRefundCount + itemsByDate[row.Date] = item + } + for _, row := range settlements { + item := itemsByDate[row.Date] + item.Date = row.Date + item.PlatformIncomeAmount = money.Round(row.PlatformIncomeAmount) + item.OwnerShouldIncomeAmount = money.Round(row.OwnerShouldIncomeAmount) + item.OwnerWalletIncomeAmount = money.Round(row.OwnerWalletIncomeAmount) + item.SettlementDiffAmount = money.Round(row.OwnerShouldIncomeAmount - row.OwnerWalletIncomeAmount) + item.SettledOrderCount = row.SettledOrderCount + itemsByDate[row.Date] = item + } + + items := make([]FinanceDailyDTO, 0, len(itemsByDate)) + for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) { + items = append(items, itemsByDate[day.Format("2006-01-02")]) + } + return items, nil +} + +func (r *Repository) financeDetailBaseQuery(query DetailQuery) *gorm.DB { + db := r.db.Table("rental_orders AS ro"). + Select(`ro.id AS order_id, ro.order_no, ro.status AS order_status, ro.settlement_status, ro.refund_status, + ro.renter_id, COALESCE(ru.phone, '') AS renter_phone, COALESCE(ru.nickname, '') AS renter_nickname, + ro.owner_id, COALESCE(ou.phone, '') AS owner_phone, COALESCE(ou.nickname, '') AS owner_nickname, + ro.rent_amount AS order_rent_amount, ro.deposit_amount AS order_deposit_amount, + COALESCE(oc.rent_amount, 0) AS checkout_rent_amount, + COALESCE(oc.renter_refund_amount, 0) AS checkout_renter_refund, + COALESCE(oc.owner_income_amount, 0) AS checkout_owner_income, + COALESCE(oc.platform_fee, 0) AS checkout_platform_fee, + COALESCE(w.owner_wallet_income_amount, 0) AS owner_wallet_income_amount, + COALESCE(p.paid_amount_cent, 0) AS paid_amount_cent, + COALESCE(p.refunded_amount_cent, 0) AS refunded_amount_cent, + COALESCE(p.refunding_amount_cent, 0) AS refunding_amount_cent, + COALESCE(p.failed_refund_amount_cent, 0) AS failed_refund_amount_cent, + COALESCE(p.paid_amount_cent, 0) - COALESCE(p.refunded_amount_cent, 0) AS channel_net_amount_cent, + COALESCE(oc.platform_fee, 0) + ((COALESCE(oc.owner_income_amount, 0) - COALESCE(w.owner_wallet_income_amount, 0))) AS platform_net_amount, + COALESCE(oc.owner_income_amount, 0) - COALESCE(w.owner_wallet_income_amount, 0) AS settlement_diff_amount, + CASE + WHEN COALESCE(p.failed_refund_amount_cent, 0) > 0 THEN 'refund_failed' + WHEN COALESCE(p.refunding_amount_cent, 0) > 0 OR ro.refund_status = 'refunding' THEN 'refund_pending' + WHEN ABS(COALESCE(oc.owner_income_amount, 0) - COALESCE(w.owner_wallet_income_amount, 0)) >= 0.05 THEN 'settlement_diff' + ELSE 'normal' + END AS finance_status, + ro.created_at, ro.settled_at`). + Joins("LEFT JOIN users AS ru ON ru.id = ro.renter_id"). + Joins("LEFT JOIN users AS ou ON ou.id = ro.owner_id"). + Joins("LEFT JOIN (?) AS oc ON oc.order_id = ro.id", acceptedCheckoutSubquery(r.db)). + Joins("LEFT JOIN (?) AS p ON p.order_id = ro.id", orderPaymentSubquery(r.db)). + Joins("LEFT JOIN (?) AS w ON w.order_id = ro.id", ownerWalletIncomeSubquery(r.db)) + + if query.OrderNo != "" { + db = db.Where("ro.order_no = ?", query.OrderNo) + } + if query.UserID > 0 { + db = db.Where("(ro.renter_id = ? OR ro.owner_id = ?)", query.UserID, query.UserID) + } + if query.OrderStatus != "" { + db = db.Where("ro.status = ?", query.OrderStatus) + } + if query.SettlementStatus != "" { + db = db.Where("ro.settlement_status = ?", query.SettlementStatus) + } + if !query.StartDate.IsZero() && !query.EndDate.IsZero() { + if query.DateType == "created" { + db = db.Where("ro.created_at >= ? AND ro.created_at <= ?", query.StartDate, query.EndDate) + } else { + db = db.Where("ro.settled_at >= ? AND ro.settled_at <= ?", query.StartDate, query.EndDate) + } + } + return db +} + +func acceptedCheckoutSubquery(db *gorm.DB) *gorm.DB { + latest := db.Table("order_checkouts").Select("MAX(id) AS id").Where("status = ?", "accepted").Group("order_id") + return db.Table("order_checkouts AS oc"). + Select("oc.*"). + Joins("JOIN (?) AS latest ON latest.id = oc.id", latest) +} + +func orderPaymentSubquery(db *gorm.DB) *gorm.DB { + return db.Table("payment_orders"). + Select(`order_id, + COALESCE(SUM(CASE WHEN biz_type = 'order_pay' AND status = 'paid' THEN amount_cent ELSE 0 END), 0) AS paid_amount_cent, + COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'refunded' THEN amount_cent ELSE 0 END), 0) AS refunded_amount_cent, + COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'refunding' THEN amount_cent ELSE 0 END), 0) AS refunding_amount_cent, + COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'failed' THEN amount_cent ELSE 0 END), 0) AS failed_refund_amount_cent`, + refundBizTypes(), refundBizTypes(), refundBizTypes()). + Group("order_id") +} + +func ownerWalletIncomeSubquery(db *gorm.DB) *gorm.DB { + return db.Table("wallet_ledger"). + Select("order_id, COALESCE(SUM(amount), 0) AS owner_wallet_income_amount"). + Where("direction = ? AND biz_type IN ? AND order_id IS NOT NULL", "in", []string{"owner_income", "deposit_compensation"}). + Group("order_id") +} + +func refundBizTypes() []string { + return []string{ + "cancel_refund", + "admin_close_refund", + "admin_refund", + "checkout_refund", + "deposit_refund", + "rent_refund", + "arbitration_refund", + } +} + +func dayStart(value time.Time) time.Time { + loc := timeutil.ShanghaiLocation() + local := value.In(loc) + return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, loc) +} + +type paymentSummaryRow struct { + TotalFlowAmountCent int64 + TotalRefundAmountCent int64 + PendingRefundAmountCent int64 + SuccessfulPayCount int64 + SuccessfulRefundCount int64 + PendingRefundCount int64 +} + +type settlementSummaryRow struct { + PlatformIncomeAmount float64 + OwnerShouldIncomeAmount float64 + OwnerWalletIncomeAmount float64 + SettledOrderCount int64 +} + +type dailyPaymentRow struct { + Date string + TotalFlowAmountCent int64 + TotalRefundAmountCent int64 + PendingRefundAmountCent int64 + SuccessfulPayCount int64 + SuccessfulRefundCount int64 + PendingRefundCount int64 +} + +type dailySettlementRow struct { + Date string + PlatformIncomeAmount float64 + OwnerShouldIncomeAmount float64 + OwnerWalletIncomeAmount float64 + SettledOrderCount int64 +} + +type financeDetailRow struct { + OrderID uint64 + OrderNo string + OrderStatus string + SettlementStatus string + RefundStatus string + RenterID uint64 + RenterPhone string + RenterNickname string + OwnerID uint64 + OwnerPhone string + OwnerNickname string + OrderRentAmount float64 + OrderDepositAmount float64 + CheckoutRentAmount float64 + CheckoutRenterRefund float64 + CheckoutOwnerIncome float64 + CheckoutPlatformFee float64 + OwnerWalletIncomeAmount float64 + PaidAmountCent int64 + RefundedAmountCent int64 + RefundingAmountCent int64 + FailedRefundAmountCent int64 + ChannelNetAmountCent int64 + PlatformNetAmount float64 + SettlementDiffAmount float64 + FinanceStatus string + CreatedAt time.Time + SettledAt *time.Time +} + +func (r financeDetailRow) toDTO() FinanceDetailDTO { + diff := money.Round(r.SettlementDiffAmount) + status := r.FinanceStatus + if status == "" { + status = "normal" + } + if math.Abs(diff) < 0.05 && status == "settlement_diff" { + status = "normal" + } + return FinanceDetailDTO{ + OrderID: r.OrderID, + OrderNo: r.OrderNo, + OrderStatus: r.OrderStatus, + SettlementStatus: r.SettlementStatus, + RefundStatus: r.RefundStatus, + RenterID: r.RenterID, + RenterPhone: r.RenterPhone, + RenterNickname: r.RenterNickname, + OwnerID: r.OwnerID, + OwnerPhone: r.OwnerPhone, + OwnerNickname: r.OwnerNickname, + OrderRentAmount: money.Round(r.OrderRentAmount), + OrderDepositAmount: money.Round(r.OrderDepositAmount), + CheckoutRentAmount: money.Round(r.CheckoutRentAmount), + CheckoutRenterRefund: money.Round(r.CheckoutRenterRefund), + CheckoutOwnerIncome: money.Round(r.CheckoutOwnerIncome), + CheckoutPlatformFee: money.Round(r.CheckoutPlatformFee), + OwnerWalletIncomeAmount: money.Round(r.OwnerWalletIncomeAmount), + PaidAmountCent: r.PaidAmountCent, + RefundedAmountCent: r.RefundedAmountCent, + RefundingAmountCent: r.RefundingAmountCent, + FailedRefundAmountCent: r.FailedRefundAmountCent, + ChannelNetAmountCent: r.ChannelNetAmountCent, + PlatformNetAmount: money.Round(r.PlatformNetAmount), + SettlementDiffAmount: diff, + FinanceStatus: status, + CreatedAt: r.CreatedAt, + SettledAt: r.SettledAt, + } +} diff --git a/backend/internal/modules/adminfinance/service.go b/backend/internal/modules/adminfinance/service.go new file mode 100644 index 0000000..760c53f --- /dev/null +++ b/backend/internal/modules/adminfinance/service.go @@ -0,0 +1,36 @@ +package adminfinance + +import "errors" + +var ErrDependencyUnavailable = errors.New("dependency unavailable") + +type Service struct { + repo *Repository +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) Dashboard(query DashboardQuery) (*DashboardDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.Dashboard(query) +} + +func (s *Service) Details(query DetailQuery) (*PaginatedResult, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if query.Page < 1 { + query.Page = 1 + } + if query.PageSize < 1 { + query.PageSize = 20 + } + if query.PageSize > 100 { + query.PageSize = 100 + } + return s.repo.Details(query) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 7d3bd1a..2134a2e 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -11,6 +11,7 @@ import ( "hfb_sys/backend/internal/modules/adminaudit" "hfb_sys/backend/internal/modules/adminauth" "hfb_sys/backend/internal/modules/admindashboard" + "hfb_sys/backend/internal/modules/adminfinance" "hfb_sys/backend/internal/modules/adminmgr" "hfb_sys/backend/internal/modules/adminrole" "hfb_sys/backend/internal/modules/adminuser" @@ -78,6 +79,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } adminDashboardService := admindashboard.NewService(adminDashboardRepo) adminDashboardHandler := admindashboard.NewHandler(adminDashboardService) + var adminFinanceRepo *adminfinance.Repository + if deps.DB != nil { + adminFinanceRepo = adminfinance.NewRepository(deps.DB) + } + adminFinanceService := adminfinance.NewService(adminFinanceRepo) + adminFinanceHandler := adminfinance.NewHandler(adminFinanceService) var adminUserRepo *adminuser.Repository if deps.DB != nil { adminUserRepo = adminuser.NewRepository(deps.DB) @@ -424,6 +431,8 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.POST("/listings/:id/mark-abnormal", requirePerm("listing:offline"), listingHandler.AdminMarkAbnormal) adminRoutes.GET("/disputes", requirePerm("dispute:view"), disputeHandler.AdminList) adminRoutes.POST("/disputes/:id/arbitrate", requirePerm("dispute:arbitrate"), disputeHandler.AdminArbitrate) + adminRoutes.GET("/finance/dashboard", requirePerm("wallet:view"), adminFinanceHandler.Dashboard) + adminRoutes.GET("/finance/details", requirePerm("wallet:view"), adminFinanceHandler.Details) adminRoutes.GET("/wallet/ledger", requirePerm("wallet:view"), walletHandler.AdminLedger) adminRoutes.GET("/payments", requirePerm("wallet:view"), paymentHandler.AdminList) diff --git a/frontend/components.d.ts b/frontend/components.d.ts index f3673ce..90947ac 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -25,6 +25,7 @@ declare module 'vue' { ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] ElCollapse: typeof import('element-plus/es')['ElCollapse'] ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem'] + ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] ElDialog: typeof import('element-plus/es')['ElDialog'] diff --git a/frontend/src/features/admin/api/adminFinance.ts b/frontend/src/features/admin/api/adminFinance.ts new file mode 100644 index 0000000..0cd2c67 --- /dev/null +++ b/frontend/src/features/admin/api/adminFinance.ts @@ -0,0 +1,116 @@ +import { apiClient } from '@/shared/api/client' +import type { ApiResponse, PaginatedResult } from '@/shared/types/types' + +export interface FinanceSummary { + total_flow_amount_cent: number + total_refund_amount_cent: number + pending_refund_amount_cent: number + channel_net_amount_cent: number + platform_income_amount: number + owner_should_income_amount: number + owner_wallet_income_amount: number + settlement_diff_amount: number + successful_pay_count: number + successful_refund_count: number + pending_refund_count: number + settled_order_count: number + financial_exception_count: number +} + +export interface FinanceDailyItem { + date: string + total_flow_amount_cent: number + total_refund_amount_cent: number + pending_refund_amount_cent: number + channel_net_amount_cent: number + platform_income_amount: number + owner_should_income_amount: number + owner_wallet_income_amount: number + settlement_diff_amount: number + successful_pay_count: number + successful_refund_count: number + pending_refund_count: number + settled_order_count: number +} + +export interface FinanceDashboard { + summary: FinanceSummary + daily_items: FinanceDailyItem[] + generated_at: string +} + +export interface FinanceDetail { + order_id: number + order_no: string + order_status: string + settlement_status: string + refund_status: string + renter_id: number + renter_phone: string + renter_nickname: string + owner_id: number + owner_phone: string + owner_nickname: string + order_rent_amount: number + order_deposit_amount: number + checkout_rent_amount: number + checkout_renter_refund: number + checkout_owner_income: number + checkout_platform_fee: number + owner_wallet_income_amount: number + paid_amount_cent: number + refunded_amount_cent: number + refunding_amount_cent: number + failed_refund_amount_cent: number + channel_net_amount_cent: number + platform_net_amount: number + settlement_diff_amount: number + finance_status: string + created_at: string + settled_at?: string +} + +export interface FinanceDateQuery { + start_date?: string + end_date?: string +} + +export interface FinanceDetailQuery extends FinanceDateQuery { + order_no?: string + user_id?: string + order_status?: string + settlement_status?: string + date_type?: string + page?: number + page_size?: number +} + +function cleanParams(query: object) { + return Object.fromEntries( + Object.entries(query).filter(([, value]) => value !== '' && value !== undefined) + ) +} + +export async function fetchFinanceDashboard(query: FinanceDateQuery = {}) { + const { data } = await apiClient.get>('/admin/finance/dashboard', { + params: cleanParams(query), + }) + return { + ...data.data, + daily_items: Array.isArray(data.data?.daily_items) ? data.data.daily_items : [], + } +} + +export async function fetchFinanceDetails(query: FinanceDetailQuery = {}) { + const { data } = await apiClient.get>>( + '/admin/finance/details', + { params: cleanParams(query) } + ) + const result = data.data + return { + items: Array.isArray(result?.items) ? result.items : [], + total: Number(result?.total ?? 0), + page: Number(result?.page ?? query.page ?? 1), + page_size: Number(result?.page_size ?? query.page_size ?? 20), + } +} diff --git a/frontend/src/features/admin/components/WithdrawalDetailDialog.vue b/frontend/src/features/admin/components/WithdrawalDetailDialog.vue index 3246394..eddb476 100644 --- a/frontend/src/features/admin/components/WithdrawalDetailDialog.vue +++ b/frontend/src/features/admin/components/WithdrawalDetailDialog.vue @@ -4,6 +4,7 @@ import { ref } from 'vue' import type { WithdrawalDetail } from '@/features/admin/api/adminWithdrawal' import { reviewWithdrawal, confirmPayment } from '@/features/admin/api/adminWithdrawal' +import { formatMoney } from '@/shared/utils/money' const props = defineProps<{ modelValue: boolean @@ -160,15 +161,15 @@ function accountTypeLabel(type: string) { - ¥{{ withdrawal.amount.toFixed(2) }} + ¥{{ formatMoney(withdrawal.amount) }} - ¥{{ withdrawal.fee.toFixed(2) }} + ¥{{ formatMoney(withdrawal.fee) }} - ¥{{ withdrawal.actual_amount.toFixed(2) }} + ¥{{ formatMoney(withdrawal.actual_amount) }} @@ -273,7 +274,7 @@ function accountTypeLabel(type: string) { > diff --git a/frontend/src/features/admin/index.ts b/frontend/src/features/admin/index.ts index 0bc8ee7..102fc8a 100644 --- a/frontend/src/features/admin/index.ts +++ b/frontend/src/features/admin/index.ts @@ -4,6 +4,7 @@ export * from './api/adminUsers' export * from './api/adminMgr' export * from './api/adminWallet' export * from './api/adminPayments' +export * from './api/adminFinance' export * from './api/adminAudit' export * from './api/systemConfigs' export * from './composables/useAdminTable' diff --git a/frontend/src/features/admin/views/AdminFinanceDashboardView.vue b/frontend/src/features/admin/views/AdminFinanceDashboardView.vue new file mode 100644 index 0000000..218e228 --- /dev/null +++ b/frontend/src/features/admin/views/AdminFinanceDashboardView.vue @@ -0,0 +1,189 @@ + + + + + diff --git a/frontend/src/features/admin/views/AdminFinanceDetailsView.vue b/frontend/src/features/admin/views/AdminFinanceDetailsView.vue new file mode 100644 index 0000000..a0e94fd --- /dev/null +++ b/frontend/src/features/admin/views/AdminFinanceDetailsView.vue @@ -0,0 +1,252 @@ + + + + + diff --git a/frontend/src/features/admin/views/AdminListingDetailView.vue b/frontend/src/features/admin/views/AdminListingDetailView.vue index d5e0123..43721d2 100644 --- a/frontend/src/features/admin/views/AdminListingDetailView.vue +++ b/frontend/src/features/admin/views/AdminListingDetailView.vue @@ -5,6 +5,7 @@ import { computed, onMounted, ref } from 'vue' import { useRoute } from 'vue-router' import { fetchAdminFileBlob } from '@/shared/api/files' +import { formatMoneyWithSymbol } from '@/shared/utils/money' import { adminMarkListingAbnormal, adminOfflineListing, @@ -78,7 +79,7 @@ async function submitAction() { } function money(value: number) { - return `¥${Math.round(Number(value || 0))}` + return formatMoneyWithSymbol(value) } function listingPrice(row: Listing) { diff --git a/frontend/src/features/admin/views/AdminListingReviewView.vue b/frontend/src/features/admin/views/AdminListingReviewView.vue index 063ffa5..54d8f18 100644 --- a/frontend/src/features/admin/views/AdminListingReviewView.vue +++ b/frontend/src/features/admin/views/AdminListingReviewView.vue @@ -4,6 +4,7 @@ import { ElMessage, ElMessageBox } from 'element-plus' import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { fetchAdminFileBlob } from '@/shared/api/files' +import { formatMoneyWithSymbol } from '@/shared/utils/money' import { adjustListingReviewPrice, approveListing, @@ -242,7 +243,7 @@ function openEvidence(row: Listing) { } function money(value: number) { - return `¥${Math.round(Number(value || 0))}` + return formatMoneyWithSymbol(value) } function quantity(value: number) { diff --git a/frontend/src/features/admin/views/AdminOrderDetailView.vue b/frontend/src/features/admin/views/AdminOrderDetailView.vue index ab481ea..4b55363 100644 --- a/frontend/src/features/admin/views/AdminOrderDetailView.vue +++ b/frontend/src/features/admin/views/AdminOrderDetailView.vue @@ -15,6 +15,7 @@ import { type RefundStatus, } from '@/features/orders' import { fetchAdminPayments, type AdminPayment } from '@/features/admin/api/adminPayments' +import { formatMoney, formatMoneyWithSymbol } from '@/shared/utils/money' import { handoffStatusLabel, orderStatusLabel } from '@/utils/statusLabels' import { formatDateTime } from '@/utils/time' import { formatListingNo } from '@/utils/listingDisplay' @@ -107,7 +108,7 @@ function orderEstimatedEndAt() { } function money(value: unknown) { - return Math.round(Number(value || 0)) + return formatMoney(Number(value || 0)) } async function handleRefund() { @@ -167,7 +168,7 @@ function paymentBizTypeLabel(type: string) { } function moneyCent(value: number) { - return `¥${(Number(value || 0) / 100).toFixed(2)}` + return formatMoneyWithSymbol(Number(value || 0) / 100) } function formatHandoffRecordType(type: string) { @@ -240,7 +241,7 @@ function formatHandoffRecordType(type: string) { 退款状态 {{ refundStatusLabel(refundStatus.refund_status) }} ¥{{ (refundStatus.refund_amount_cent / 100).toFixed(2) }}{{ moneyCent(refundStatus.refund_amount_cent) }} diff --git a/frontend/src/features/admin/views/AdminPaymentConfigsView.vue b/frontend/src/features/admin/views/AdminPaymentConfigsView.vue index 797047b..bac741f 100644 --- a/frontend/src/features/admin/views/AdminPaymentConfigsView.vue +++ b/frontend/src/features/admin/views/AdminPaymentConfigsView.vue @@ -11,6 +11,7 @@ import { type PaymentConfig, } from '@/features/admin/api/paymentConfig' import { formatDateTime } from '@/utils/time' +import { formatMoney } from '@/shared/utils/money' import { readError } from '@/utils/error' import PaymentConfigDialog from '../components/PaymentConfigDialog.vue' @@ -230,7 +231,7 @@ function formatEnvironment(env: string) { } function formatAmount(amountCent: number) { - return (amountCent / 100).toFixed(2) + return formatMoney(amountCent / 100) } function getStatusType(status: string) { diff --git a/frontend/src/features/admin/views/AdminPaymentsView.vue b/frontend/src/features/admin/views/AdminPaymentsView.vue index 2fa04c0..ddacb25 100644 --- a/frontend/src/features/admin/views/AdminPaymentsView.vue +++ b/frontend/src/features/admin/views/AdminPaymentsView.vue @@ -3,6 +3,7 @@ import { Document, Search } from '@element-plus/icons-vue' import { computed, onMounted, reactive, ref } from 'vue' import { fetchAdminPayments, type AdminPayment } from '@/features/admin/api/adminPayments' +import { formatMoneyWithSymbol } from '@/shared/utils/money' import { formatDateTime } from '@/utils/time' import AdminTablePagination from '../components/AdminTablePagination.vue' @@ -69,7 +70,7 @@ async function handlePageChange() { } function moneyCent(value: number) { - return `¥${(Number(value || 0) / 100).toFixed(2)}` + return formatMoneyWithSymbol(Number(value || 0) / 100) } function paymentStatusType(status: string) { diff --git a/frontend/src/features/admin/views/AdminUsersView.vue b/frontend/src/features/admin/views/AdminUsersView.vue index b00ca3b..abb1462 100644 --- a/frontend/src/features/admin/views/AdminUsersView.vue +++ b/frontend/src/features/admin/views/AdminUsersView.vue @@ -9,6 +9,7 @@ import { unfreezeAdminUser, type AdminUserItem, } from '@/features/admin/api/adminUsers' +import { formatMoney } from '@/shared/utils/money' import { useAdminPaginatedTable } from '@/features/admin/composables/useAdminPaginatedTable' import { userStatusLabel } from '@/utils/statusLabels' import { formatDateTime } from '@/utils/time' @@ -93,7 +94,7 @@ function readError(error: unknown, fallback: string) { } function money(value: number | string | undefined) { - return Number(value || 0).toFixed(2) + return formatMoney(Number(value || 0)) } diff --git a/frontend/src/features/admin/views/AdminWalletLedgerView.vue b/frontend/src/features/admin/views/AdminWalletLedgerView.vue index 5b185df..a657b55 100644 --- a/frontend/src/features/admin/views/AdminWalletLedgerView.vue +++ b/frontend/src/features/admin/views/AdminWalletLedgerView.vue @@ -3,6 +3,7 @@ import { Search } from '@element-plus/icons-vue' import { computed, onMounted, reactive, ref } from 'vue' import { fetchAdminWalletLedger, type AdminWalletLedger } from '@/features/admin/api/adminWallet' +import { formatMoneyWithSymbol } from '@/shared/utils/money' import { balanceTypeLabel, ledgerDirectionLabel } from '@/utils/statusLabels' import { formatDateTime } from '@/utils/time' import AdminTablePagination from '../components/AdminTablePagination.vue' @@ -64,7 +65,7 @@ async function handlePageChange() { } function money(value: number) { - return `¥${Math.round(Number(value || 0))}` + return formatMoneyWithSymbol(value) } function directionType(direction: string) { diff --git a/frontend/src/features/admin/views/AdminWithdrawalsView.vue b/frontend/src/features/admin/views/AdminWithdrawalsView.vue index 9f499e7..25bb8de 100644 --- a/frontend/src/features/admin/views/AdminWithdrawalsView.vue +++ b/frontend/src/features/admin/views/AdminWithdrawalsView.vue @@ -8,6 +8,7 @@ import { confirmPayment, type WithdrawalDetail, } from '@/features/admin/api/adminWithdrawal' +import { formatMoney } from '@/shared/utils/money' import WithdrawalDetailDialog from '../components/WithdrawalDetailDialog.vue' @@ -219,7 +220,7 @@ function onDetailDialogSaved() { diff --git a/frontend/src/features/auth/views/MobileProfileView.vue b/frontend/src/features/auth/views/MobileProfileView.vue index 790290e..c2a1b89 100644 --- a/frontend/src/features/auth/views/MobileProfileView.vue +++ b/frontend/src/features/auth/views/MobileProfileView.vue @@ -13,6 +13,7 @@ import { import { fetchPostRentalNotice, type PostRentalNotice } from '@/features/orders/api/orders' import { formatDateMinute } from '@/utils/time' import { uploadFile } from '@/shared/api/files' +import { formatMoney } from '@/shared/utils/money' const session = useSessionStore() const router = useRouter() @@ -316,7 +317,7 @@ function resolveAvatarURL(url: string | undefined | null) {
账户可用余额(元) - ¥{{ Math.round(Number(balance || 0)) }} + ¥{{ formatMoney(balance) }}
@@ -417,7 +418,7 @@ function resolveAvatarURL(url: string | undefined | null) { {{ formatDateMinute(item.created_at) }}
- {{ item.direction === 'in' ? '+' : '-' }}¥{{ Math.round(Number(item.amount || 0)) }} + {{ item.direction === 'in' ? '+' : '-' }}¥{{ formatMoney(item.amount) }}
diff --git a/frontend/src/features/listings/components/ListingCard.vue b/frontend/src/features/listings/components/ListingCard.vue index 3e3bd55..cac4fb4 100644 --- a/frontend/src/features/listings/components/ListingCard.vue +++ b/frontend/src/features/listings/components/ListingCard.vue @@ -2,6 +2,7 @@ import { computed } from 'vue' import { RouterLink } from 'vue-router' import type { Listing } from '@/features/listings' +import { formatMoney } from '@/shared/utils/money' import { formatHafCoinM, getCoinWan, @@ -135,11 +136,11 @@ function formatStatNumber(value: number) {
总租金 - ¥{{ getListingDisplayPrice(listing) }} + ¥{{ formatMoney(getListingDisplayPrice(listing)) }}
押金 - ¥{{ listing.deposit_amount }} + ¥{{ formatMoney(listing.deposit_amount) }}
diff --git a/frontend/src/features/listings/views/ListingDetailView.vue b/frontend/src/features/listings/views/ListingDetailView.vue index 3e2e6ca..8b76acf 100644 --- a/frontend/src/features/listings/views/ListingDetailView.vue +++ b/frontend/src/features/listings/views/ListingDetailView.vue @@ -67,7 +67,7 @@ onMounted(async () => { }) const orderTotal = computed(() => { - if (!listing.value) return '0' + if (!listing.value) return '0.0' return formatMoney(getListingDisplayPrice(listing.value)) }) @@ -102,7 +102,7 @@ const detailMetrics = computed(() => { tone: 'coin', }, { label: '价格', value: `¥${orderTotal.value}`, tone: 'price' }, - { label: '押金', value: `¥${listing.value.deposit_amount}`, tone: '' }, + { label: '押金', value: `¥${formatMoney(listing.value.deposit_amount)}`, tone: '' }, ] }) @@ -401,7 +401,7 @@ function listingPrice(item: Listing) { {{ resource.quantity }} {{ resource.mode || '--' }} - ¥{{ resource.amount }} + ¥{{ formatMoney(resource.amount) }} {{ resource.price || '¥0' }} 无额外收费 @@ -458,14 +458,14 @@ function listingPrice(item: Listing) {
基础租金 - ¥{{ orderPriceBreakdown.rent }} + ¥{{ formatMoney(orderPriceBreakdown.rent) }}
额外物品 - ¥{{ orderPriceBreakdown.consumable }} + ¥{{ formatMoney(orderPriceBreakdown.consumable) }}
- 押金另付 ¥{{ listing.deposit_amount }} + 押金另付 ¥{{ formatMoney(listing.deposit_amount) }}
diff --git a/frontend/src/features/listings/views/ListingsView.vue b/frontend/src/features/listings/views/ListingsView.vue index a241740..2576ae4 100644 --- a/frontend/src/features/listings/views/ListingsView.vue +++ b/frontend/src/features/listings/views/ListingsView.vue @@ -6,6 +6,7 @@ import { type ListingPublishOptions, } from '@/features/listings/api/listingOptions' import { fetchListings, type Listing } from '@/features/listings/api/listings' +import { formatMoney } from '@/shared/utils/money' import { defaultHomeAnnouncements, defaultHomeBanners, @@ -620,8 +621,8 @@ function parseQuantityUnit(price: string) {
- ¥{{ getListingDisplayPrice(item) }} - 押金¥{{ item.deposit_amount }} + ¥{{ formatMoney(getListingDisplayPrice(item)) }} + 押金¥{{ formatMoney(item.deposit_amount) }}
diff --git a/frontend/src/features/listings/views/MobileHomeView.vue b/frontend/src/features/listings/views/MobileHomeView.vue index 0d45e15..5f01f42 100644 --- a/frontend/src/features/listings/views/MobileHomeView.vue +++ b/frontend/src/features/listings/views/MobileHomeView.vue @@ -5,6 +5,7 @@ import { showToast } from 'vant' import MobileBottomNav from '@/components/MobileBottomNav.vue' import { ensureSupportChat } from '@/features/chats/api/chats' +import { formatMoney } from '@/shared/utils/money' import { emptyListingPublishOptions, type ListingPublishOptions, @@ -695,8 +696,8 @@ function chipTone(label: string) { diff --git a/frontend/src/features/listings/views/MobileListingDetailView.vue b/frontend/src/features/listings/views/MobileListingDetailView.vue index 8897237..e00ec97 100644 --- a/frontend/src/features/listings/views/MobileListingDetailView.vue +++ b/frontend/src/features/listings/views/MobileListingDetailView.vue @@ -94,8 +94,8 @@ const detailMetrics = computed(() => { value: dailyLoss ? `${dailyLoss}/天` : '--', tone: 'coin', }, - { label: '价格', value: `¥${getListingDisplayPrice(listing.value)}`, tone: 'price' }, - { label: '押金', value: `¥${listing.value.deposit_amount}`, tone: '' }, + { label: '价格', value: `¥${formatMoney(getListingDisplayPrice(listing.value))}`, tone: 'price' }, + { label: '押金', value: `¥${formatMoney(listing.value.deposit_amount)}`, tone: '' }, ] }) @@ -369,7 +369,7 @@ async function copyListingCode() { {{ resource.quantity }} {{ resource.mode || '--' }} - ¥{{ resource.amount }} + ¥{{ formatMoney(resource.amount) }} {{ resource.price || '¥0' }} 无额外收费 @@ -414,8 +414,8 @@ async function copyListingCode() { ¥{{ orderTotal }}
- 租金 ¥{{ orderPriceBreakdown.rent }} - 额外 ¥{{ orderPriceBreakdown.consumable }} + 租金 ¥{{ formatMoney(orderPriceBreakdown.rent) }} + 额外 ¥{{ formatMoney(orderPriceBreakdown.consumable) }}
支付金额 - ¥{{ (activePayment.amount_cent / 100).toFixed(2) }} + ¥{{ formatMoney(activePayment.amount_cent / 100) }}
diff --git a/frontend/src/features/orders/views/OrdersView.vue b/frontend/src/features/orders/views/OrdersView.vue index 7626d22..640a802 100644 --- a/frontend/src/features/orders/views/OrdersView.vue +++ b/frontend/src/features/orders/views/OrdersView.vue @@ -5,6 +5,7 @@ import { useRoute, useRouter } from 'vue-router' import { CopyDocument, Search } from '@element-plus/icons-vue' import { fetchOrders, type Order } from '@/features/orders' +import { formatMoney } from '@/shared/utils/money' import { useSessionStore } from '@/stores/session' import { orderStatusLabel } from '@/utils/statusLabels' import { formatDateTime } from '@/utils/time' @@ -139,7 +140,7 @@ function ownerActualIncome(order: Order) { } function money(value: unknown) { - return Math.round(Number(value || 0)) + return formatMoney(Number(value || 0)) } function shortenOrderNo(orderNo: string) { diff --git a/frontend/src/features/seller/views/MobileSellerListingCreateView.vue b/frontend/src/features/seller/views/MobileSellerListingCreateView.vue index e6ed237..e2fdc66 100644 --- a/frontend/src/features/seller/views/MobileSellerListingCreateView.vue +++ b/frontend/src/features/seller/views/MobileSellerListingCreateView.vue @@ -4,6 +4,7 @@ import { computed, ref } from 'vue' import MobileBottomNav from '@/components/MobileBottomNav.vue' import { usePublishForm } from '@/features/seller/composables/usePublishForm' +import { formatMoney } from '@/shared/utils/money' import type { AgreementContent } from '@/features/listings/api/listingOptions' const { @@ -549,7 +550,7 @@ function selectRadio(value: T, setter: (value: T) => void) { (value: T, setter: (value: T) => void) {
{{ item.label }} ¥{{ - item.amount + formatMoney(item.amount) }}
@@ -579,15 +580,15 @@ function selectDailyLoss(value: string | number) {
纯币基础价 - {{ calculatedCoinBasePrice ? `¥${calculatedCoinBasePrice}` : '--' }} + {{ calculatedCoinBasePrice ? `¥${formatMoney(calculatedCoinBasePrice)}` : '--' }}
额外消耗品 - ¥{{ calculatedConsumablePrice }} + ¥{{ formatMoney(calculatedConsumablePrice) }}
发布价格 - {{ calculatedSellerPrice ? `¥${calculatedSellerPrice}` : '--' }} + {{ calculatedSellerPrice ? `¥${formatMoney(calculatedSellerPrice)}` : '--' }}
@@ -607,21 +608,21 @@ function selectDailyLoss(value: string | number) {
卖家发布价格 - {{ calculatedSellerPrice ? `¥${calculatedSellerPrice}` : '--' }} + {{ calculatedSellerPrice ? `¥${formatMoney(calculatedSellerPrice)}` : '--' }}
价格明细
纯币价格 - {{ calculatedCoinBasePrice ? `¥${calculatedCoinBasePrice}` : '--' }} + {{ calculatedCoinBasePrice ? `¥${formatMoney(calculatedCoinBasePrice)}` : '--' }}
额外物品价格 - ¥{{ calculatedConsumablePrice }} + ¥{{ formatMoney(calculatedConsumablePrice) }}
押金价格 - {{ form.deposit_amount === '' ? '--' : `¥${form.deposit_amount}` }} + {{ form.deposit_amount === '' ? '--' : `¥${formatMoney(Number(form.deposit_amount))}` }}
diff --git a/frontend/src/features/seller/views/SellerListingsView.vue b/frontend/src/features/seller/views/SellerListingsView.vue index 26b9d43..b59a15c 100644 --- a/frontend/src/features/seller/views/SellerListingsView.vue +++ b/frontend/src/features/seller/views/SellerListingsView.vue @@ -9,6 +9,7 @@ import { submitListingReview, type Listing, } from '@/features/listings' +import { formatMoney, formatMoneyWithSymbol } from '@/shared/utils/money' import { listingReviewStatusLabel, listingStatusLabel } from '@/utils/statusLabels' import { formatListingCode, getListingSellerPrice } from '@/utils/listingDisplay' @@ -103,7 +104,7 @@ function replaceListing(next: Listing) { } function listingPrice(row: Listing) { - return `¥${Math.round(getListingSellerPrice(row))}` + return formatMoneyWithSymbol(getListingSellerPrice(row)) } async function copyListingCode(row: Listing) { @@ -226,7 +227,7 @@ function isPendingReview(row: Listing) {
押金 - ¥{{ item.deposit_amount }} + ¥{{ formatMoney(item.deposit_amount) }}
diff --git a/frontend/src/features/wallet/views/WalletView.vue b/frontend/src/features/wallet/views/WalletView.vue index 640c6ba..610c31f 100644 --- a/frontend/src/features/wallet/views/WalletView.vue +++ b/frontend/src/features/wallet/views/WalletView.vue @@ -222,7 +222,7 @@ function readError(error: unknown, fallback: string) { } function formatMoney(value: number) { - return `¥${Number(value || 0).toFixed(2)}` + return `¥${(Math.round(Number(value || 0) * 10) / 10).toFixed(1)}` } function walletBizTypeLabel(type: string) { diff --git a/frontend/src/features/wallet/views/WithdrawalView.vue b/frontend/src/features/wallet/views/WithdrawalView.vue index cc67c56..bb12621 100644 --- a/frontend/src/features/wallet/views/WithdrawalView.vue +++ b/frontend/src/features/wallet/views/WithdrawalView.vue @@ -13,6 +13,7 @@ import { type WithdrawalRequest, } from '../api/withdrawal' import { fetchWalletBalance, type WalletAccount } from '../api/wallet' +import { formatMoney, formatMoneyWithSymbol } from '@/shared/utils/money' const router = useRouter() @@ -84,7 +85,7 @@ async function handleSubmit() { try { await ElMessageBox.confirm( - `确认提现 ¥${withdrawForm.value.amount.toFixed(2)} 到 ${selectedAccount.value?.account_name} (${selectedAccount.value?.account_no}) ?`, + `确认提现 ${formatMoneyWithSymbol(withdrawForm.value.amount)} 到 ${selectedAccount.value?.account_name} (${selectedAccount.value?.account_no}) ?`, '确认提现', { confirmButtonText: '确认', @@ -175,7 +176,7 @@ function accountTypeLabel(type: string) { 可用余额 -
¥{{ account?.available_balance.toFixed(2) || '0.00' }}
+
¥{{ formatMoney(account?.available_balance) }}
@@ -183,7 +184,7 @@ function accountTypeLabel(type: string) { 冻结余额
- ¥{{ account?.frozen_balance.toFixed(2) || '0.00' }} + ¥{{ formatMoney(account?.frozen_balance) }}
@@ -251,7 +252,7 @@ function accountTypeLabel(type: string) {
- ¥{{ withdrawForm.amount > 0 ? withdrawForm.amount.toFixed(2) : '0.00' }} + ¥{{ formatMoney(withdrawForm.amount > 0 ? withdrawForm.amount : 0) }}
@@ -264,7 +265,7 @@ function accountTypeLabel(type: string) { show-icon style="margin-bottom: 16px" > - 余额不足,可用余额:¥{{ account.available_balance.toFixed(2) }} + 余额不足,可用余额:¥{{ formatMoney(account.available_balance) }} @@ -308,7 +309,7 @@ function accountTypeLabel(type: string) { diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index dd57259..962a8f7 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -97,6 +97,18 @@ const allNavGroups: NavGroup[] = [ index: 'finance', icon: Coin, children: [ + { + label: '财务仪表盘', + to: '/admin/finance/dashboard', + icon: DataLine, + permission: 'wallet:view', + }, + { + label: '财务明细', + to: '/admin/finance/details', + icon: Document, + permission: 'wallet:view', + }, { label: '资金流水', to: '/admin/wallet-ledger', icon: Wallet, permission: 'wallet:view' }, { label: '支付流水', to: '/admin/payments', icon: CreditCard, permission: 'wallet:view' }, { label: '提现审核', to: '/admin/withdrawals', icon: Money, permission: 'withdrawal:list' }, diff --git a/frontend/src/router/adminRoutes.ts b/frontend/src/router/adminRoutes.ts index 5687633..375738a 100644 --- a/frontend/src/router/adminRoutes.ts +++ b/frontend/src/router/adminRoutes.ts @@ -64,6 +64,18 @@ export const adminRoutes: RouteRecordRaw[] = [ component: () => import('@/features/admin/views/AdminChatsView.vue'), meta: adminMeta, }, + { + path: '/admin/finance/dashboard', + name: 'admin-finance-dashboard', + component: () => import('@/features/admin/views/AdminFinanceDashboardView.vue'), + meta: adminMeta, + }, + { + path: '/admin/finance/details', + name: 'admin-finance-details', + component: () => import('@/features/admin/views/AdminFinanceDetailsView.vue'), + meta: adminMeta, + }, { path: '/admin/wallet-ledger', name: 'admin-wallet-ledger', diff --git a/frontend/src/shared/composables/useMoney.ts b/frontend/src/shared/composables/useMoney.ts index da286a5..87ac217 100644 --- a/frontend/src/shared/composables/useMoney.ts +++ b/frontend/src/shared/composables/useMoney.ts @@ -1,3 +1,5 @@ +import { formatMoneyWithSymbol } from '@/shared/utils/money' + export function useMoney() { - return (value: number | undefined | null) => `¥${Math.round(Number(value || 0))}` + return (value: number | undefined | null) => formatMoneyWithSymbol(value) } diff --git a/frontend/src/shared/utils/money.ts b/frontend/src/shared/utils/money.ts index 333a32a..2c37be5 100644 --- a/frontend/src/shared/utils/money.ts +++ b/frontend/src/shared/utils/money.ts @@ -9,7 +9,7 @@ * @example roundMoney(12.36) -> 12.4 */ export function roundMoney(value: number): number { - return Math.round(value * 10) / 10 + return Math.round(Number(value || 0) * 10) / 10 } /** diff --git a/frontend/src/utils/listingDisplay.ts b/frontend/src/utils/listingDisplay.ts index 40b7cfe..6aff303 100644 --- a/frontend/src/utils/listingDisplay.ts +++ b/frontend/src/utils/listingDisplay.ts @@ -46,13 +46,13 @@ export function getListingDisplayPrice(item: Listing) { export function getListingRentPrice(item: Listing) { const buyerCoinBasePrice = readPriceBreakdownNumber(item, 'buyer_coin_base_price') - if (buyerCoinBasePrice > 0) return Math.round(buyerCoinBasePrice) - return Math.max(0, Math.round(getListingDisplayPrice(item) - getListingConsumablePrice(item))) + if (buyerCoinBasePrice > 0) return roundMoney(buyerCoinBasePrice) + return Math.max(0, roundMoney(getListingDisplayPrice(item) - getListingConsumablePrice(item))) } export function getListingConsumablePrice(item: Listing) { const consumablePrice = readPriceBreakdownNumber(item, 'consumable_price') - if (consumablePrice > 0) return Math.round(consumablePrice) + if (consumablePrice > 0) return roundMoney(consumablePrice) return getListingResources(item).reduce((sum, resource) => sum + resource.amount, 0) } @@ -145,7 +145,7 @@ export function getListingResources(item: Listing): ListingDisplayResource[] { mode: typeof row.mode === 'string' ? row.mode : '', amount: row.mode === '收费' - ? Math.round( + ? roundMoney( readUnknownNumber(row.quantity) * readUnitPrice(typeof row.price === 'string' ? row.price : '') ) diff --git a/frontend/src/utils/pricing.ts b/frontend/src/utils/pricing.ts index c302a9d..f618a49 100644 --- a/frontend/src/utils/pricing.ts +++ b/frontend/src/utils/pricing.ts @@ -23,7 +23,7 @@ export const commonOnlineTimes = [ ] export function roundMoney(value: number) { - return Math.round(value) + return Math.round(value * 10) / 10 } export function roundRatio(value: number) { @@ -31,7 +31,7 @@ export function roundRatio(value: number) { } export function formatNumber(value: number) { - return Number.isInteger(value) ? `${value}` : `${Math.round(value * 10) / 10}` + return (Math.round(value * 10) / 10).toFixed(1) } export function readUnitPrice(priceText: string) {