新增财务仪表盘并统一金额到角

This commit is contained in:
yml
2026-06-09 00:13:03 +08:00
parent a2a0489158
commit 2dfa2611fd
40 changed files with 1324 additions and 70 deletions
@@ -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"`
}
@@ -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", "财务数据暂时不可用")
}
}
@@ -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,
}
}
@@ -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)
}
+9
View File
@@ -11,6 +11,7 @@ import (
"hfb_sys/backend/internal/modules/adminaudit" "hfb_sys/backend/internal/modules/adminaudit"
"hfb_sys/backend/internal/modules/adminauth" "hfb_sys/backend/internal/modules/adminauth"
"hfb_sys/backend/internal/modules/admindashboard" "hfb_sys/backend/internal/modules/admindashboard"
"hfb_sys/backend/internal/modules/adminfinance"
"hfb_sys/backend/internal/modules/adminmgr" "hfb_sys/backend/internal/modules/adminmgr"
"hfb_sys/backend/internal/modules/adminrole" "hfb_sys/backend/internal/modules/adminrole"
"hfb_sys/backend/internal/modules/adminuser" "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) adminDashboardService := admindashboard.NewService(adminDashboardRepo)
adminDashboardHandler := admindashboard.NewHandler(adminDashboardService) 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 var adminUserRepo *adminuser.Repository
if deps.DB != nil { if deps.DB != nil {
adminUserRepo = adminuser.NewRepository(deps.DB) 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.POST("/listings/:id/mark-abnormal", requirePerm("listing:offline"), listingHandler.AdminMarkAbnormal)
adminRoutes.GET("/disputes", requirePerm("dispute:view"), disputeHandler.AdminList) adminRoutes.GET("/disputes", requirePerm("dispute:view"), disputeHandler.AdminList)
adminRoutes.POST("/disputes/:id/arbitrate", requirePerm("dispute:arbitrate"), disputeHandler.AdminArbitrate) 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("/wallet/ledger", requirePerm("wallet:view"), walletHandler.AdminLedger)
adminRoutes.GET("/payments", requirePerm("wallet:view"), paymentHandler.AdminList) adminRoutes.GET("/payments", requirePerm("wallet:view"), paymentHandler.AdminList)
+1
View File
@@ -25,6 +25,7 @@ declare module 'vue' {
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCollapse: typeof import('element-plus/es')['ElCollapse'] ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem'] ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog'] ElDialog: typeof import('element-plus/es')['ElDialog']
@@ -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<ApiResponse<FinanceDashboard>>('/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<ApiResponse<PaginatedResult<FinanceDetail>>>(
'/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),
}
}
@@ -4,6 +4,7 @@ import { ref } from 'vue'
import type { WithdrawalDetail } from '@/features/admin/api/adminWithdrawal' import type { WithdrawalDetail } from '@/features/admin/api/adminWithdrawal'
import { reviewWithdrawal, confirmPayment } from '@/features/admin/api/adminWithdrawal' import { reviewWithdrawal, confirmPayment } from '@/features/admin/api/adminWithdrawal'
import { formatMoney } from '@/shared/utils/money'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -160,15 +161,15 @@ function accountTypeLabel(type: string) {
<el-descriptions :column="2" border> <el-descriptions :column="2" border>
<el-descriptions-item label="提现金额"> <el-descriptions-item label="提现金额">
<span style="color: #f56c6c; font-weight: 600; font-size: 16px"> <span style="color: #f56c6c; font-weight: 600; font-size: 16px">
¥{{ withdrawal.amount.toFixed(2) }} ¥{{ formatMoney(withdrawal.amount) }}
</span> </span>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="手续费"> <el-descriptions-item label="手续费">
¥{{ withdrawal.fee.toFixed(2) }} ¥{{ formatMoney(withdrawal.fee) }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="实际到账" :span="2"> <el-descriptions-item label="实际到账" :span="2">
<span style="color: #67c23a; font-weight: 600; font-size: 16px"> <span style="color: #67c23a; font-weight: 600; font-size: 16px">
¥{{ withdrawal.actual_amount.toFixed(2) }} ¥{{ formatMoney(withdrawal.actual_amount) }}
</span> </span>
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
@@ -273,7 +274,7 @@ function accountTypeLabel(type: string) {
> >
<template #title> <template #title>
<div style="font-size: 13px"> <div style="font-size: 13px">
请手动转账 <strong>¥{{ withdrawal.actual_amount.toFixed(2) }}</strong> 到用户收款账号 请手动转账 <strong>¥{{ formatMoney(withdrawal.actual_amount) }}</strong> 到用户收款账号
完成后点击"确认打款"按钮 完成后点击"确认打款"按钮
</div> </div>
</template> </template>
+1
View File
@@ -4,6 +4,7 @@ export * from './api/adminUsers'
export * from './api/adminMgr' export * from './api/adminMgr'
export * from './api/adminWallet' export * from './api/adminWallet'
export * from './api/adminPayments' export * from './api/adminPayments'
export * from './api/adminFinance'
export * from './api/adminAudit' export * from './api/adminAudit'
export * from './api/systemConfigs' export * from './api/systemConfigs'
export * from './composables/useAdminTable' export * from './composables/useAdminTable'
@@ -0,0 +1,189 @@
<script setup lang="ts">
import { Refresh, Search } from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref } from 'vue'
import {
fetchFinanceDashboard,
type FinanceDashboard,
type FinanceDailyItem,
} from '@/features/admin/api/adminFinance'
import { formatMoneyWithSymbol } from '@/shared/utils/money'
import { formatDateTime } from '@/utils/time'
const loading = ref(false)
const dashboard = ref<FinanceDashboard | null>(null)
const filters = reactive({
start_date: defaultStartDate(),
end_date: defaultEndDate(),
})
const dailyItems = computed(() => dashboard.value?.daily_items ?? [])
onMounted(loadDashboard)
async function loadDashboard() {
loading.value = true
try {
dashboard.value = await fetchFinanceDashboard(filters)
} finally {
loading.value = false
}
}
function moneyCent(value: number) {
return formatMoneyWithSymbol(Number(value || 0) / 100)
}
function money(value: number) {
return formatMoneyWithSymbol(value)
}
function defaultStartDate() {
const date = new Date()
date.setDate(date.getDate() - 6)
return formatInputDate(date)
}
function defaultEndDate() {
return formatInputDate(new Date())
}
function formatInputDate(date: Date) {
return date.toISOString().slice(0, 10)
}
function diffType(value: number) {
return Math.abs(Number(value || 0)) >= 0.05 ? 'danger' : 'success'
}
function rowDiffClass(row: FinanceDailyItem) {
return Math.abs(Number(row.settlement_diff_amount || 0)) >= 0.05 ? 'amount-danger' : ''
}
</script>
<template>
<section class="page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Finance Dashboard</p>
<h1>财务仪表盘</h1>
<p>按天查看第三方收款退款平台收入号主入账和结算差异</p>
</div>
<div class="toolbar-actions">
<el-date-picker
v-model="filters.start_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="开始日期"
/>
<el-date-picker
v-model="filters.end_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="结束日期"
/>
<el-button type="primary" :icon="Search" :loading="loading" @click="loadDashboard">
查询
</el-button>
<el-button :icon="Refresh" :loading="loading" @click="loadDashboard">刷新</el-button>
</div>
</div>
<div v-if="dashboard" class="metric-grid">
<div class="metric-card">
<span>总流水</span>
<strong>{{ moneyCent(dashboard.summary.total_flow_amount_cent) }}</strong>
<small>{{ dashboard.summary.successful_pay_count }} 笔成功收款</small>
</div>
<div class="metric-card">
<span>总退款</span>
<strong>{{ moneyCent(dashboard.summary.total_refund_amount_cent) }}</strong>
<small>{{ dashboard.summary.successful_refund_count }} 笔成功退款</small>
</div>
<div class="metric-card">
<span>渠道净流入</span>
<strong>{{ moneyCent(dashboard.summary.channel_net_amount_cent) }}</strong>
<small>成功收款 - 成功退款</small>
</div>
<div class="metric-card">
<span>平台收入</span>
<strong>{{ money(dashboard.summary.platform_income_amount) }}</strong>
<small>{{ dashboard.summary.settled_order_count }} 个已结算订单</small>
</div>
<div class="metric-card">
<span>号主应得</span>
<strong>{{ money(dashboard.summary.owner_should_income_amount) }}</strong>
<small>结账单口径</small>
</div>
<div class="metric-card">
<span>号主实际入账</span>
<strong>{{ money(dashboard.summary.owner_wallet_income_amount) }}</strong>
<small>钱包流水口径</small>
</div>
<div class="metric-card">
<span>退款中</span>
<strong>{{ moneyCent(dashboard.summary.pending_refund_amount_cent) }}</strong>
<small>{{ dashboard.summary.pending_refund_count }} 笔待回执</small>
</div>
<div class="metric-card">
<span>结算差异</span>
<strong>
<el-tag :type="diffType(dashboard.summary.settlement_diff_amount)">
{{ money(dashboard.summary.settlement_diff_amount) }}
</el-tag>
</strong>
<small>{{ dashboard.summary.financial_exception_count }} 个异常订单</small>
</div>
</div>
<el-table v-loading="loading" class="table-panel" :data="dailyItems">
<el-table-column prop="date" label="日期" width="130" />
<el-table-column label="总流水" width="130">
<template #default="{ row }">{{ moneyCent(row.total_flow_amount_cent) }}</template>
</el-table-column>
<el-table-column label="总退款" width="130">
<template #default="{ row }">{{ moneyCent(row.total_refund_amount_cent) }}</template>
</el-table-column>
<el-table-column label="渠道净流入" width="140">
<template #default="{ row }">{{ moneyCent(row.channel_net_amount_cent) }}</template>
</el-table-column>
<el-table-column label="平台收入" width="130">
<template #default="{ row }">{{ money(row.platform_income_amount) }}</template>
</el-table-column>
<el-table-column label="号主应得" width="130">
<template #default="{ row }">{{ money(row.owner_should_income_amount) }}</template>
</el-table-column>
<el-table-column label="号主入账" width="130">
<template #default="{ row }">{{ money(row.owner_wallet_income_amount) }}</template>
</el-table-column>
<el-table-column label="结算差异" width="130">
<template #default="{ row }">
<span :class="rowDiffClass(row)">{{ money(row.settlement_diff_amount) }}</span>
</template>
</el-table-column>
<el-table-column label="收款/退款/结算" min-width="170">
<template #default="{ row }">
{{ row.successful_pay_count }} / {{ row.successful_refund_count }} /
{{ row.settled_order_count }}
</template>
</el-table-column>
</el-table>
<p v-if="dashboard" class="generated-at">
数据生成时间{{ formatDateTime(dashboard.generated_at) }}
</p>
</section>
</template>
<style scoped>
.amount-danger {
color: #dc2626;
font-weight: 700;
}
.generated-at {
margin: 12px 0 0;
color: #64748b;
font-size: 13px;
}
</style>
@@ -0,0 +1,252 @@
<script setup lang="ts">
import { Search } from '@element-plus/icons-vue'
import { onMounted, reactive, ref } from 'vue'
import {
fetchFinanceDetails,
type FinanceDetail,
} from '@/features/admin/api/adminFinance'
import { formatMoneyWithSymbol } from '@/shared/utils/money'
import { orderStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
import AdminTablePagination from '../components/AdminTablePagination.vue'
const loading = ref(false)
const details = ref<FinanceDetail[]>([])
const currentPage = ref(1)
const currentPageSize = ref(20)
const total = ref(0)
const filters = reactive({
start_date: defaultStartDate(),
end_date: defaultEndDate(),
date_type: 'settled',
order_no: '',
user_id: '',
order_status: '',
settlement_status: '',
})
onMounted(loadDetails)
async function loadDetails() {
loading.value = true
try {
const result = await fetchFinanceDetails({
...filters,
page: currentPage.value,
page_size: currentPageSize.value,
})
details.value = result.items
total.value = result.total
} finally {
loading.value = false
}
}
function resetFilters() {
filters.start_date = defaultStartDate()
filters.end_date = defaultEndDate()
filters.date_type = 'settled'
filters.order_no = ''
filters.user_id = ''
filters.order_status = ''
filters.settlement_status = ''
currentPage.value = 1
void loadDetails()
}
async function handlePageChange() {
await loadDetails()
}
function money(value: number) {
return formatMoneyWithSymbol(value)
}
function moneyCent(value: number) {
return formatMoneyWithSymbol(Number(value || 0) / 100)
}
function financeStatusLabel(status: string) {
const map: Record<string, string> = {
normal: '正常',
refund_pending: '退款中',
refund_failed: '退款失败',
settlement_diff: '结算差异',
}
return map[status] || status
}
function financeStatusType(status: string) {
if (status === 'normal') return 'success'
if (status === 'settlement_diff' || status === 'refund_failed') return 'danger'
if (status === 'refund_pending') return 'warning'
return 'info'
}
function settlementStatusLabel(status: string) {
const map: Record<string, string> = {
unsettled: '未结算',
settling: '结算中',
settled: '已结算',
}
return map[status] || status || '-'
}
function defaultStartDate() {
const date = new Date()
date.setDate(date.getDate() - 29)
return formatInputDate(date)
}
function defaultEndDate() {
return formatInputDate(new Date())
}
function formatInputDate(date: Date) {
return date.toISOString().slice(0, 10)
}
</script>
<template>
<section class="page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Finance Details</p>
<h1>财务明细</h1>
<p>按订单核对收款退款平台收入号主应得与钱包实际入账</p>
</div>
<div class="toolbar-actions">
<el-button @click="resetFilters">重置</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="loadDetails">
查询
</el-button>
</div>
</div>
<el-form class="filter-panel" label-position="top">
<el-form-item label="日期类型">
<el-select v-model="filters.date_type" class="full-control">
<el-option label="结算日期" value="settled" />
<el-option label="下单日期" value="created" />
</el-select>
</el-form-item>
<el-form-item label="开始日期">
<el-date-picker
v-model="filters.start_date"
class="full-control"
type="date"
value-format="YYYY-MM-DD"
/>
</el-form-item>
<el-form-item label="结束日期">
<el-date-picker
v-model="filters.end_date"
class="full-control"
type="date"
value-format="YYYY-MM-DD"
/>
</el-form-item>
<el-form-item label="订单号">
<el-input v-model="filters.order_no" clearable placeholder="按订单号筛选" />
</el-form-item>
<el-form-item label="用户 ID">
<el-input v-model="filters.user_id" clearable placeholder="租客或号主 ID" />
</el-form-item>
<el-form-item label="订单状态">
<el-select v-model="filters.order_status" clearable placeholder="全部状态" class="full-control">
<el-option label="待支付" value="pending_payment" />
<el-option label="使用中" value="renting" />
<el-option label="待结账" value="pending_checkout_confirm" />
<el-option label="已完成" value="completed" />
<el-option label="已关闭" value="closed" />
</el-select>
</el-form-item>
<el-form-item label="结算状态">
<el-select
v-model="filters.settlement_status"
clearable
placeholder="全部结算"
class="full-control"
>
<el-option label="未结算" value="unsettled" />
<el-option label="已结算" value="settled" />
</el-select>
</el-form-item>
</el-form>
<el-table v-loading="loading" class="table-panel" :data="details">
<el-table-column label="订单" min-width="180">
<template #default="{ row }">
<RouterLink :to="`/admin/orders/${row.order_id}`">{{ row.order_no }}</RouterLink>
</template>
</el-table-column>
<el-table-column label="状态" width="150">
<template #default="{ row }">
<div>{{ orderStatusLabel(row.order_status) }}</div>
<small>{{ settlementStatusLabel(row.settlement_status) }}</small>
</template>
</el-table-column>
<el-table-column label="租客/号主" min-width="170">
<template #default="{ row }">
<div>{{ row.renter_phone || row.renter_nickname || row.renter_id }}</div>
<small>{{ row.owner_phone || row.owner_nickname || row.owner_id }}</small>
</template>
</el-table-column>
<el-table-column label="收款" width="110">
<template #default="{ row }">{{ moneyCent(row.paid_amount_cent) }}</template>
</el-table-column>
<el-table-column label="已退款" width="110">
<template #default="{ row }">{{ moneyCent(row.refunded_amount_cent) }}</template>
</el-table-column>
<el-table-column label="退款中" width="110">
<template #default="{ row }">{{ moneyCent(row.refunding_amount_cent) }}</template>
</el-table-column>
<el-table-column label="净流入" width="110">
<template #default="{ row }">{{ moneyCent(row.channel_net_amount_cent) }}</template>
</el-table-column>
<el-table-column label="平台收入" width="110">
<template #default="{ row }">{{ money(row.checkout_platform_fee) }}</template>
</el-table-column>
<el-table-column label="号主应得" width="110">
<template #default="{ row }">{{ money(row.checkout_owner_income) }}</template>
</el-table-column>
<el-table-column label="号主入账" width="110">
<template #default="{ row }">{{ money(row.owner_wallet_income_amount) }}</template>
</el-table-column>
<el-table-column label="差异" width="110">
<template #default="{ row }">
<span :class="{ 'amount-danger': Math.abs(row.settlement_diff_amount) >= 0.05 }">
{{ money(row.settlement_diff_amount) }}
</span>
</template>
</el-table-column>
<el-table-column label="财务状态" width="120">
<template #default="{ row }">
<el-tag :type="financeStatusType(row.finance_status)">
{{ financeStatusLabel(row.finance_status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="结算时间" min-width="170">
<template #default="{ row }">{{ row.settled_at ? formatDateTime(row.settled_at) : '-' }}</template>
</el-table-column>
</el-table>
<AdminTablePagination
v-if="total > 0"
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:loading="loading"
@page-change="handlePageChange"
/>
</section>
</template>
<style scoped>
.amount-danger {
color: #dc2626;
font-weight: 700;
}
</style>
@@ -5,6 +5,7 @@ import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { fetchAdminFileBlob } from '@/shared/api/files' import { fetchAdminFileBlob } from '@/shared/api/files'
import { formatMoneyWithSymbol } from '@/shared/utils/money'
import { import {
adminMarkListingAbnormal, adminMarkListingAbnormal,
adminOfflineListing, adminOfflineListing,
@@ -78,7 +79,7 @@ async function submitAction() {
} }
function money(value: number) { function money(value: number) {
return `¥${Math.round(Number(value || 0))}` return formatMoneyWithSymbol(value)
} }
function listingPrice(row: Listing) { function listingPrice(row: Listing) {
@@ -4,6 +4,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { fetchAdminFileBlob } from '@/shared/api/files' import { fetchAdminFileBlob } from '@/shared/api/files'
import { formatMoneyWithSymbol } from '@/shared/utils/money'
import { import {
adjustListingReviewPrice, adjustListingReviewPrice,
approveListing, approveListing,
@@ -242,7 +243,7 @@ function openEvidence(row: Listing) {
} }
function money(value: number) { function money(value: number) {
return `¥${Math.round(Number(value || 0))}` return formatMoneyWithSymbol(value)
} }
function quantity(value: number) { function quantity(value: number) {
@@ -15,6 +15,7 @@ import {
type RefundStatus, type RefundStatus,
} from '@/features/orders' } from '@/features/orders'
import { fetchAdminPayments, type AdminPayment } from '@/features/admin/api/adminPayments' import { fetchAdminPayments, type AdminPayment } from '@/features/admin/api/adminPayments'
import { formatMoney, formatMoneyWithSymbol } from '@/shared/utils/money'
import { handoffStatusLabel, orderStatusLabel } from '@/utils/statusLabels' import { handoffStatusLabel, orderStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time' import { formatDateTime } from '@/utils/time'
import { formatListingNo } from '@/utils/listingDisplay' import { formatListingNo } from '@/utils/listingDisplay'
@@ -107,7 +108,7 @@ function orderEstimatedEndAt() {
} }
function money(value: unknown) { function money(value: unknown) {
return Math.round(Number(value || 0)) return formatMoney(Number(value || 0))
} }
async function handleRefund() { async function handleRefund() {
@@ -167,7 +168,7 @@ function paymentBizTypeLabel(type: string) {
} }
function moneyCent(value: number) { function moneyCent(value: number) {
return `¥${(Number(value || 0) / 100).toFixed(2)}` return formatMoneyWithSymbol(Number(value || 0) / 100)
} }
function formatHandoffRecordType(type: string) { function formatHandoffRecordType(type: string) {
@@ -240,7 +241,7 @@ function formatHandoffRecordType(type: string) {
<span>退款状态</span> <span>退款状态</span>
<strong>{{ refundStatusLabel(refundStatus.refund_status) }}</strong> <strong>{{ refundStatusLabel(refundStatus.refund_status) }}</strong>
<small v-if="refundStatus.refund_amount_cent > 0" <small v-if="refundStatus.refund_amount_cent > 0"
>¥{{ (refundStatus.refund_amount_cent / 100).toFixed(2) }}</small >{{ moneyCent(refundStatus.refund_amount_cent) }}</small
> >
</div> </div>
</div> </div>
@@ -11,6 +11,7 @@ import {
type PaymentConfig, type PaymentConfig,
} from '@/features/admin/api/paymentConfig' } from '@/features/admin/api/paymentConfig'
import { formatDateTime } from '@/utils/time' import { formatDateTime } from '@/utils/time'
import { formatMoney } from '@/shared/utils/money'
import { readError } from '@/utils/error' import { readError } from '@/utils/error'
import PaymentConfigDialog from '../components/PaymentConfigDialog.vue' import PaymentConfigDialog from '../components/PaymentConfigDialog.vue'
@@ -230,7 +231,7 @@ function formatEnvironment(env: string) {
} }
function formatAmount(amountCent: number) { function formatAmount(amountCent: number) {
return (amountCent / 100).toFixed(2) return formatMoney(amountCent / 100)
} }
function getStatusType(status: string) { function getStatusType(status: string) {
@@ -3,6 +3,7 @@ import { Document, Search } from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { fetchAdminPayments, type AdminPayment } from '@/features/admin/api/adminPayments' import { fetchAdminPayments, type AdminPayment } from '@/features/admin/api/adminPayments'
import { formatMoneyWithSymbol } from '@/shared/utils/money'
import { formatDateTime } from '@/utils/time' import { formatDateTime } from '@/utils/time'
import AdminTablePagination from '../components/AdminTablePagination.vue' import AdminTablePagination from '../components/AdminTablePagination.vue'
@@ -69,7 +70,7 @@ async function handlePageChange() {
} }
function moneyCent(value: number) { function moneyCent(value: number) {
return `¥${(Number(value || 0) / 100).toFixed(2)}` return formatMoneyWithSymbol(Number(value || 0) / 100)
} }
function paymentStatusType(status: string) { function paymentStatusType(status: string) {
@@ -9,6 +9,7 @@ import {
unfreezeAdminUser, unfreezeAdminUser,
type AdminUserItem, type AdminUserItem,
} from '@/features/admin/api/adminUsers' } from '@/features/admin/api/adminUsers'
import { formatMoney } from '@/shared/utils/money'
import { useAdminPaginatedTable } from '@/features/admin/composables/useAdminPaginatedTable' import { useAdminPaginatedTable } from '@/features/admin/composables/useAdminPaginatedTable'
import { userStatusLabel } from '@/utils/statusLabels' import { userStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time' import { formatDateTime } from '@/utils/time'
@@ -93,7 +94,7 @@ function readError(error: unknown, fallback: string) {
} }
function money(value: number | string | undefined) { function money(value: number | string | undefined) {
return Number(value || 0).toFixed(2) return formatMoney(Number(value || 0))
} }
</script> </script>
@@ -3,6 +3,7 @@ import { Search } from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { fetchAdminWalletLedger, type AdminWalletLedger } from '@/features/admin/api/adminWallet' import { fetchAdminWalletLedger, type AdminWalletLedger } from '@/features/admin/api/adminWallet'
import { formatMoneyWithSymbol } from '@/shared/utils/money'
import { balanceTypeLabel, ledgerDirectionLabel } from '@/utils/statusLabels' import { balanceTypeLabel, ledgerDirectionLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time' import { formatDateTime } from '@/utils/time'
import AdminTablePagination from '../components/AdminTablePagination.vue' import AdminTablePagination from '../components/AdminTablePagination.vue'
@@ -64,7 +65,7 @@ async function handlePageChange() {
} }
function money(value: number) { function money(value: number) {
return `¥${Math.round(Number(value || 0))}` return formatMoneyWithSymbol(value)
} }
function directionType(direction: string) { function directionType(direction: string) {
@@ -8,6 +8,7 @@ import {
confirmPayment, confirmPayment,
type WithdrawalDetail, type WithdrawalDetail,
} from '@/features/admin/api/adminWithdrawal' } from '@/features/admin/api/adminWithdrawal'
import { formatMoney } from '@/shared/utils/money'
import WithdrawalDetailDialog from '../components/WithdrawalDetailDialog.vue' import WithdrawalDetailDialog from '../components/WithdrawalDetailDialog.vue'
@@ -219,7 +220,7 @@ function onDetailDialogSaved() {
</el-table-column> </el-table-column>
<el-table-column label="提现金额" width="120" align="right"> <el-table-column label="提现金额" width="120" align="right">
<template #default="{ row }"> <template #default="{ row }">
<span style="color: #f56c6c; font-weight: 600"> ¥{{ row.amount.toFixed(2) }} </span> <span style="color: #f56c6c; font-weight: 600"> ¥{{ formatMoney(row.amount) }} </span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="收款方式" min-width="180"> <el-table-column label="收款方式" min-width="180">
@@ -13,6 +13,7 @@ import {
import { fetchPostRentalNotice, type PostRentalNotice } from '@/features/orders/api/orders' import { fetchPostRentalNotice, type PostRentalNotice } from '@/features/orders/api/orders'
import { formatDateMinute } from '@/utils/time' import { formatDateMinute } from '@/utils/time'
import { uploadFile } from '@/shared/api/files' import { uploadFile } from '@/shared/api/files'
import { formatMoney } from '@/shared/utils/money'
const session = useSessionStore() const session = useSessionStore()
const router = useRouter() const router = useRouter()
@@ -316,7 +317,7 @@ function resolveAvatarURL(url: string | undefined | null) {
<div class="balance-row"> <div class="balance-row">
<div class="balance-info"> <div class="balance-info">
<span class="balance-label">账户可用余额()</span> <span class="balance-label">账户可用余额()</span>
<strong class="balance-value">¥{{ Math.round(Number(balance || 0)) }}</strong> <strong class="balance-value">¥{{ formatMoney(balance) }}</strong>
</div> </div>
<button class="withdraw-btn" @click="handleWithdraw">提现</button> <button class="withdraw-btn" @click="handleWithdraw">提现</button>
</div> </div>
@@ -417,7 +418,7 @@ function resolveAvatarURL(url: string | undefined | null) {
<span class="ledger-time">{{ formatDateMinute(item.created_at) }}</span> <span class="ledger-time">{{ formatDateMinute(item.created_at) }}</span>
</div> </div>
<div class="ledger-right" :class="item.direction === 'in' ? 'in-color' : 'out-color'"> <div class="ledger-right" :class="item.direction === 'in' ? 'in-color' : 'out-color'">
{{ item.direction === 'in' ? '+' : '-' }}¥{{ Math.round(Number(item.amount || 0)) }} {{ item.direction === 'in' ? '+' : '-' }}¥{{ formatMoney(item.amount) }}
</div> </div>
</div> </div>
</div> </div>
@@ -2,6 +2,7 @@
import { computed } from 'vue' import { computed } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import type { Listing } from '@/features/listings' import type { Listing } from '@/features/listings'
import { formatMoney } from '@/shared/utils/money'
import { import {
formatHafCoinM, formatHafCoinM,
getCoinWan, getCoinWan,
@@ -135,11 +136,11 @@ function formatStatNumber(value: number) {
<div class="card-price"> <div class="card-price">
<div class="price-item total"> <div class="price-item total">
<small>总租金</small> <small>总租金</small>
<strong>¥{{ getListingDisplayPrice(listing) }}</strong> <strong>¥{{ formatMoney(getListingDisplayPrice(listing)) }}</strong>
</div> </div>
<div class="price-item deposit"> <div class="price-item deposit">
<small>押金</small> <small>押金</small>
<span>¥{{ listing.deposit_amount }}</span> <span>¥{{ formatMoney(listing.deposit_amount) }}</span>
</div> </div>
<div class="price-action"> <div class="price-action">
<button class="rent-btn">立即租用</button> <button class="rent-btn">立即租用</button>
@@ -67,7 +67,7 @@ onMounted(async () => {
}) })
const orderTotal = computed(() => { const orderTotal = computed(() => {
if (!listing.value) return '0' if (!listing.value) return '0.0'
return formatMoney(getListingDisplayPrice(listing.value)) return formatMoney(getListingDisplayPrice(listing.value))
}) })
@@ -102,7 +102,7 @@ const detailMetrics = computed(() => {
tone: 'coin', tone: 'coin',
}, },
{ label: '价格', value: `¥${orderTotal.value}`, tone: 'price' }, { 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) {
<strong>{{ resource.quantity }}</strong> <strong>{{ resource.quantity }}</strong>
<em> <em>
<b>{{ resource.mode || '--' }}</b> <b>{{ resource.mode || '--' }}</b>
<small v-if="resource.amount > 0">¥{{ resource.amount }}</small> <small v-if="resource.amount > 0">¥{{ formatMoney(resource.amount) }}</small>
<small v-else-if="resource.mode === '收费'">{{ resource.price || '¥0' }}</small> <small v-else-if="resource.mode === '收费'">{{ resource.price || '¥0' }}</small>
<small v-else>无额外收费</small> <small v-else>无额外收费</small>
</em> </em>
@@ -458,14 +458,14 @@ function listingPrice(item: Listing) {
<div class="order-price-breakdown"> <div class="order-price-breakdown">
<div> <div>
<span>基础租金</span> <span>基础租金</span>
<strong>¥{{ orderPriceBreakdown.rent }}</strong> <strong>¥{{ formatMoney(orderPriceBreakdown.rent) }}</strong>
</div> </div>
<div> <div>
<span>额外物品</span> <span>额外物品</span>
<strong>¥{{ orderPriceBreakdown.consumable }}</strong> <strong>¥{{ formatMoney(orderPriceBreakdown.consumable) }}</strong>
</div> </div>
</div> </div>
<em>押金另付 ¥{{ listing.deposit_amount }}</em> <em>押金另付 ¥{{ formatMoney(listing.deposit_amount) }}</em>
</div> </div>
<dl class="order-check-list"> <dl class="order-check-list">
<div> <div>
@@ -6,6 +6,7 @@ import {
type ListingPublishOptions, type ListingPublishOptions,
} from '@/features/listings/api/listingOptions' } from '@/features/listings/api/listingOptions'
import { fetchListings, type Listing } from '@/features/listings/api/listings' import { fetchListings, type Listing } from '@/features/listings/api/listings'
import { formatMoney } from '@/shared/utils/money'
import { import {
defaultHomeAnnouncements, defaultHomeAnnouncements,
defaultHomeBanners, defaultHomeBanners,
@@ -620,8 +621,8 @@ function parseQuantityUnit(price: string) {
</div> </div>
</div> </div>
<div class="resource-price-box"> <div class="resource-price-box">
<strong>¥{{ getListingDisplayPrice(item) }}</strong> <strong>¥{{ formatMoney(getListingDisplayPrice(item)) }}</strong>
<span class="rent-sub">押金¥{{ item.deposit_amount }}</span> <span class="rent-sub">押金¥{{ formatMoney(item.deposit_amount) }}</span>
</div> </div>
</RouterLink> </RouterLink>
</div> </div>
@@ -5,6 +5,7 @@ import { showToast } from 'vant'
import MobileBottomNav from '@/components/MobileBottomNav.vue' import MobileBottomNav from '@/components/MobileBottomNav.vue'
import { ensureSupportChat } from '@/features/chats/api/chats' import { ensureSupportChat } from '@/features/chats/api/chats'
import { formatMoney } from '@/shared/utils/money'
import { import {
emptyListingPublishOptions, emptyListingPublishOptions,
type ListingPublishOptions, type ListingPublishOptions,
@@ -695,8 +696,8 @@ function chipTone(label: string) {
</div> </div>
<div class="card-footer"> <div class="card-footer">
<div class="price-col"> <div class="price-col">
<strong>¥{{ getListingDisplayPrice(item) }}</strong> <strong>¥{{ formatMoney(getListingDisplayPrice(item)) }}</strong>
<span class="rent-sub">押金 ¥{{ item.deposit_amount }}</span> <span class="rent-sub">押金 ¥{{ formatMoney(item.deposit_amount) }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -94,8 +94,8 @@ const detailMetrics = computed(() => {
value: dailyLoss ? `${dailyLoss}/天` : '--', value: dailyLoss ? `${dailyLoss}/天` : '--',
tone: 'coin', tone: 'coin',
}, },
{ label: '价格', value: `¥${getListingDisplayPrice(listing.value)}`, tone: 'price' }, { label: '价格', value: `¥${formatMoney(getListingDisplayPrice(listing.value))}`, tone: 'price' },
{ label: '押金', value: `¥${listing.value.deposit_amount}`, tone: '' }, { label: '押金', value: `¥${formatMoney(listing.value.deposit_amount)}`, tone: '' },
] ]
}) })
@@ -369,7 +369,7 @@ async function copyListingCode() {
<strong>{{ resource.quantity }}</strong> <strong>{{ resource.quantity }}</strong>
<em> <em>
<b>{{ resource.mode || '--' }}</b> <b>{{ resource.mode || '--' }}</b>
<small v-if="resource.amount > 0">¥{{ resource.amount }}</small> <small v-if="resource.amount > 0">¥{{ formatMoney(resource.amount) }}</small>
<small v-else-if="resource.mode === '收费'">{{ resource.price || '¥0' }}</small> <small v-else-if="resource.mode === '收费'">{{ resource.price || '¥0' }}</small>
<small v-else>无额外收费</small> <small v-else>无额外收费</small>
</em> </em>
@@ -414,8 +414,8 @@ async function copyListingCode() {
<span class="price-amount">¥{{ orderTotal }}</span> <span class="price-amount">¥{{ orderTotal }}</span>
</div> </div>
<div class="order-price-detail"> <div class="order-price-detail">
<span>租金 ¥{{ orderPriceBreakdown.rent }}</span> <span>租金 ¥{{ formatMoney(orderPriceBreakdown.rent) }}</span>
<span>额外 ¥{{ orderPriceBreakdown.consumable }}</span> <span>额外 ¥{{ formatMoney(orderPriceBreakdown.consumable) }}</span>
</div> </div>
</div> </div>
<van-button <van-button
@@ -6,6 +6,7 @@ import { showToast, showDialog } from 'vant'
import { fetchOrderChat } from '@/features/chats/api/chats' import { fetchOrderChat } from '@/features/chats/api/chats'
import { createDispute } from '@/features/disputes/api/disputes' import { createDispute } from '@/features/disputes/api/disputes'
import { uploadFile } from '@/shared/api/files' import { uploadFile } from '@/shared/api/files'
import { formatMoney } from '@/shared/utils/money'
import { import {
acceptCheckout, acceptCheckout,
cancelOrder, cancelOrder,
@@ -529,7 +530,7 @@ function readUnitPrice(priceText: string) {
} }
function roundMoney(value: number) { function roundMoney(value: number) {
return Math.round(value) return Math.round(Number(value || 0) * 10) / 10
} }
function roundQuantity(value: number) { function roundQuantity(value: number) {
@@ -537,7 +538,7 @@ function roundQuantity(value: number) {
} }
function money(value: unknown) { function money(value: unknown) {
return `${roundMoney(readNumber(value))}` return formatMoney(readNumber(value))
} }
function formatHandoffRecordType(type: string) { function formatHandoffRecordType(type: string) {
@@ -10,6 +10,7 @@ import {
type Order, type Order,
type PaymentOrder, type PaymentOrder,
} from '@/features/orders/api/orders' } from '@/features/orders/api/orders'
import { formatMoney } from '@/shared/utils/money'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
import { formatDateMinute } from '@/utils/time' import { formatDateMinute } from '@/utils/time'
import { formatListingNo } from '@/utils/listingDisplay' import { formatListingNo } from '@/utils/listingDisplay'
@@ -119,7 +120,7 @@ function readError(error: unknown, fallback: string) {
} }
function money(value: unknown) { function money(value: unknown) {
return Math.round(Number(value || 0)) return formatMoney(Number(value || 0))
} }
function isOwner(order: Order) { function isOwner(order: Order) {
@@ -8,6 +8,7 @@ import QRCode from 'qrcode'
import { fetchOrderChat } from '@/features/chats/api/chats' import { fetchOrderChat } from '@/features/chats/api/chats'
import { createDispute } from '@/features/disputes' import { createDispute } from '@/features/disputes'
import { uploadFile } from '@/shared/api/files' import { uploadFile } from '@/shared/api/files'
import { formatMoney } from '@/shared/utils/money'
import { import {
acceptCheckout, acceptCheckout,
cancelOrder, cancelOrder,
@@ -588,7 +589,7 @@ function readUnitPrice(priceText: string) {
} }
function roundMoney(value: number) { function roundMoney(value: number) {
return Math.round(value) return Math.round(Number(value || 0) * 10) / 10
} }
function roundQuantity(value: number) { function roundQuantity(value: number) {
@@ -596,7 +597,7 @@ function roundQuantity(value: number) {
} }
function money(value: unknown) { function money(value: unknown) {
return `${roundMoney(readNumber(value))}` return formatMoney(readNumber(value))
} }
function orderRentAmount(item: Order) { function orderRentAmount(item: Order) {
@@ -1252,7 +1253,7 @@ async function copyListingCode() {
<div class="pay-summary"> <div class="pay-summary">
<div class="pay-summary-row"> <div class="pay-summary-row">
<span>支付金额</span> <span>支付金额</span>
<strong>¥{{ (activePayment.amount_cent / 100).toFixed(2) }}</strong> <strong>¥{{ formatMoney(activePayment.amount_cent / 100) }}</strong>
</div> </div>
</div> </div>
<div v-if="paymentPayURL()" class="pay-qr-section"> <div v-if="paymentPayURL()" class="pay-qr-section">
@@ -5,6 +5,7 @@ import { useRoute, useRouter } from 'vue-router'
import { CopyDocument, Search } from '@element-plus/icons-vue' import { CopyDocument, Search } from '@element-plus/icons-vue'
import { fetchOrders, type Order } from '@/features/orders' import { fetchOrders, type Order } from '@/features/orders'
import { formatMoney } from '@/shared/utils/money'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
import { orderStatusLabel } from '@/utils/statusLabels' import { orderStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time' import { formatDateTime } from '@/utils/time'
@@ -139,7 +140,7 @@ function ownerActualIncome(order: Order) {
} }
function money(value: unknown) { function money(value: unknown) {
return Math.round(Number(value || 0)) return formatMoney(Number(value || 0))
} }
function shortenOrderNo(orderNo: string) { function shortenOrderNo(orderNo: string) {
@@ -4,6 +4,7 @@ import { computed, ref } from 'vue'
import MobileBottomNav from '@/components/MobileBottomNav.vue' import MobileBottomNav from '@/components/MobileBottomNav.vue'
import { usePublishForm } from '@/features/seller/composables/usePublishForm' import { usePublishForm } from '@/features/seller/composables/usePublishForm'
import { formatMoney } from '@/shared/utils/money'
import type { AgreementContent } from '@/features/listings/api/listingOptions' import type { AgreementContent } from '@/features/listings/api/listingOptions'
const { const {
@@ -549,7 +550,7 @@ function selectRadio<T extends string>(value: T, setter: (value: T) => void) {
</template> </template>
</van-field> </van-field>
<button class="deposit-recommend-btn" type="button" @click="useRecommendedDeposit"> <button class="deposit-recommend-btn" type="button" @click="useRecommendedDeposit">
推荐押金 ¥{{ recommendedDepositAmount }} 推荐押金 ¥{{ formatMoney(recommendedDepositAmount) }}
</button> </button>
<van-field label="每日损耗" required class="publish-field"> <van-field label="每日损耗" required class="publish-field">
<template #input> <template #input>
@@ -638,7 +639,7 @@ function selectRadio<T extends string>(value: T, setter: (value: T) => void) {
</template> </template>
</van-field> </van-field>
<van-field <van-field
:model-value="calculatedCoinBasePrice ? `¥${calculatedCoinBasePrice}` : ''" :model-value="calculatedCoinBasePrice ? `¥${formatMoney(calculatedCoinBasePrice)}` : ''"
label="纯币基础价" label="纯币基础价"
readonly readonly
required required
@@ -652,13 +653,13 @@ function selectRadio<T extends string>(value: T, setter: (value: T) => void) {
</template> </template>
</van-field> </van-field>
<van-field <van-field
:model-value="`¥${calculatedConsumablePrice}`" :model-value="`¥${formatMoney(calculatedConsumablePrice)}`"
label="额外消耗品" label="额外消耗品"
readonly readonly
class="publish-field result-field" class="publish-field result-field"
/> />
<van-field <van-field
:model-value="calculatedSellerPrice ? `¥${calculatedSellerPrice}` : ''" :model-value="calculatedSellerPrice ? `¥${formatMoney(calculatedSellerPrice)}` : ''"
label="卖家价格" label="卖家价格"
readonly readonly
required required
@@ -10,6 +10,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { usePublishForm } from '@/features/seller/composables/usePublishForm' import { usePublishForm } from '@/features/seller/composables/usePublishForm'
import { formatMoney } from '@/shared/utils/money'
import type { AgreementContent } from '@/features/listings/api/listingOptions' import type { AgreementContent } from '@/features/listings/api/listingOptions'
import OptionChips from './components/OptionChips.vue' import OptionChips from './components/OptionChips.vue'
import PublishSection from './components/PublishSection.vue' import PublishSection from './components/PublishSection.vue'
@@ -489,13 +490,13 @@ function selectDailyLoss(value: string | number) {
:placeholder="priceConfig.deposit_placeholder" :placeholder="priceConfig.deposit_placeholder"
/> />
<button class="recommend-button" type="button" @click="useRecommendedDeposit"> <button class="recommend-button" type="button" @click="useRecommendedDeposit">
使用推荐 ¥{{ recommendedDepositAmount }} 使用推荐 ¥{{ formatMoney(recommendedDepositAmount) }}
</button> </button>
</div> </div>
<div class="deposit-breakdown"> <div class="deposit-breakdown">
<span v-for="item in depositBreakdownItems" :key="`${item.label}-${item.count}`"> <span v-for="item in depositBreakdownItems" :key="`${item.label}-${item.count}`">
{{ item.label }}<template v-if="item.count > 1"> x{{ item.count }}</template> ¥{{ {{ item.label }}<template v-if="item.count > 1"> x{{ item.count }}</template> ¥{{
item.amount formatMoney(item.amount)
}} }}
</span> </span>
</div> </div>
@@ -579,15 +580,15 @@ function selectDailyLoss(value: string | number) {
</div> </div>
<div class="price-cell"> <div class="price-cell">
<span>纯币基础价</span> <span>纯币基础价</span>
<strong>{{ calculatedCoinBasePrice ? `¥${calculatedCoinBasePrice}` : '--' }}</strong> <strong>{{ calculatedCoinBasePrice ? `¥${formatMoney(calculatedCoinBasePrice)}` : '--' }}</strong>
</div> </div>
<div class="price-cell"> <div class="price-cell">
<span>额外消耗品</span> <span>额外消耗品</span>
<strong>¥{{ calculatedConsumablePrice }}</strong> <strong>¥{{ formatMoney(calculatedConsumablePrice) }}</strong>
</div> </div>
<div class="price-cell accent"> <div class="price-cell accent">
<span>发布价格</span> <span>发布价格</span>
<strong>{{ calculatedSellerPrice ? `¥${calculatedSellerPrice}` : '--' }}</strong> <strong>{{ calculatedSellerPrice ? `¥${formatMoney(calculatedSellerPrice)}` : '--' }}</strong>
</div> </div>
</div> </div>
@@ -607,21 +608,21 @@ function selectDailyLoss(value: string | number) {
<div class="summary-panel"> <div class="summary-panel">
<div class="summary-main"> <div class="summary-main">
<span>卖家发布价格</span> <span>卖家发布价格</span>
<strong>{{ calculatedSellerPrice ? `¥${calculatedSellerPrice}` : '--' }}</strong> <strong>{{ calculatedSellerPrice ? `¥${formatMoney(calculatedSellerPrice)}` : '--' }}</strong>
</div> </div>
<div class="summary-breakdown"> <div class="summary-breakdown">
<div class="summary-breakdown-title">价格明细</div> <div class="summary-breakdown-title">价格明细</div>
<div> <div>
<span>纯币价格</span> <span>纯币价格</span>
<strong>{{ calculatedCoinBasePrice ? `¥${calculatedCoinBasePrice}` : '--' }}</strong> <strong>{{ calculatedCoinBasePrice ? `¥${formatMoney(calculatedCoinBasePrice)}` : '--' }}</strong>
</div> </div>
<div> <div>
<span>额外物品价格</span> <span>额外物品价格</span>
<strong>¥{{ calculatedConsumablePrice }}</strong> <strong>¥{{ formatMoney(calculatedConsumablePrice) }}</strong>
</div> </div>
<div> <div>
<span>押金价格</span> <span>押金价格</span>
<strong>{{ form.deposit_amount === '' ? '--' : `¥${form.deposit_amount}` }}</strong> <strong>{{ form.deposit_amount === '' ? '--' : `¥${formatMoney(Number(form.deposit_amount))}` }}</strong>
</div> </div>
</div> </div>
<div class="summary-list"> <div class="summary-list">
@@ -9,6 +9,7 @@ import {
submitListingReview, submitListingReview,
type Listing, type Listing,
} from '@/features/listings' } from '@/features/listings'
import { formatMoney, formatMoneyWithSymbol } from '@/shared/utils/money'
import { listingReviewStatusLabel, listingStatusLabel } from '@/utils/statusLabels' import { listingReviewStatusLabel, listingStatusLabel } from '@/utils/statusLabels'
import { formatListingCode, getListingSellerPrice } from '@/utils/listingDisplay' import { formatListingCode, getListingSellerPrice } from '@/utils/listingDisplay'
@@ -103,7 +104,7 @@ function replaceListing(next: Listing) {
} }
function listingPrice(row: Listing) { function listingPrice(row: Listing) {
return `¥${Math.round(getListingSellerPrice(row))}` return formatMoneyWithSymbol(getListingSellerPrice(row))
} }
async function copyListingCode(row: Listing) { async function copyListingCode(row: Listing) {
@@ -226,7 +227,7 @@ function isPendingReview(row: Listing) {
</div> </div>
<div> <div>
<span>押金</span> <span>押金</span>
<strong>¥{{ item.deposit_amount }}</strong> <strong>¥{{ formatMoney(item.deposit_amount) }}</strong>
</div> </div>
</div> </div>
@@ -222,7 +222,7 @@ function readError(error: unknown, fallback: string) {
} }
function formatMoney(value: number) { function formatMoney(value: number) {
return `¥${Number(value || 0).toFixed(2)}` return `¥${(Math.round(Number(value || 0) * 10) / 10).toFixed(1)}`
} }
function walletBizTypeLabel(type: string) { function walletBizTypeLabel(type: string) {
@@ -13,6 +13,7 @@ import {
type WithdrawalRequest, type WithdrawalRequest,
} from '../api/withdrawal' } from '../api/withdrawal'
import { fetchWalletBalance, type WalletAccount } from '../api/wallet' import { fetchWalletBalance, type WalletAccount } from '../api/wallet'
import { formatMoney, formatMoneyWithSymbol } from '@/shared/utils/money'
const router = useRouter() const router = useRouter()
@@ -84,7 +85,7 @@ async function handleSubmit() {
try { try {
await ElMessageBox.confirm( 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: '确认', confirmButtonText: '确认',
@@ -175,7 +176,7 @@ function accountTypeLabel(type: string) {
<el-icon><Wallet /></el-icon> <el-icon><Wallet /></el-icon>
可用余额 可用余额
</div> </div>
<div class="balance-value">¥{{ account?.available_balance.toFixed(2) || '0.00' }}</div> <div class="balance-value">¥{{ formatMoney(account?.available_balance) }}</div>
</div> </div>
<div class="balance-item"> <div class="balance-item">
<div class="balance-label"> <div class="balance-label">
@@ -183,7 +184,7 @@ function accountTypeLabel(type: string) {
冻结余额 冻结余额
</div> </div>
<div class="balance-value frozen"> <div class="balance-value frozen">
¥{{ account?.frozen_balance.toFixed(2) || '0.00' }} ¥{{ formatMoney(account?.frozen_balance) }}
</div> </div>
</div> </div>
</div> </div>
@@ -251,7 +252,7 @@ function accountTypeLabel(type: string) {
<el-form-item label="到账金额"> <el-form-item label="到账金额">
<div class="actual-amount"> <div class="actual-amount">
¥{{ withdrawForm.amount > 0 ? withdrawForm.amount.toFixed(2) : '0.00' }} ¥{{ formatMoney(withdrawForm.amount > 0 ? withdrawForm.amount : 0) }}
</div> </div>
</el-form-item> </el-form-item>
@@ -264,7 +265,7 @@ function accountTypeLabel(type: string) {
show-icon show-icon
style="margin-bottom: 16px" style="margin-bottom: 16px"
> >
余额不足,可用余额:¥{{ account.available_balance.toFixed(2) }} 余额不足,可用余额:¥{{ formatMoney(account.available_balance) }}
</el-alert> </el-alert>
<el-alert type="info" :closable="false" show-icon style="margin-bottom: 16px"> <el-alert type="info" :closable="false" show-icon style="margin-bottom: 16px">
@@ -308,7 +309,7 @@ function accountTypeLabel(type: string) {
<el-table-column prop="withdraw_no" label="提现单号" min-width="180" /> <el-table-column prop="withdraw_no" label="提现单号" min-width="180" />
<el-table-column label="提现金额" width="120"> <el-table-column label="提现金额" width="120">
<template #default="{ row }"> <template #default="{ row }">
<span style="color: #f56c6c; font-weight: 600"> ¥{{ row.amount.toFixed(2) }} </span> <span style="color: #f56c6c; font-weight: 600"> ¥{{ formatMoney(row.amount) }} </span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="收款方式" width="150"> <el-table-column label="收款方式" width="150">
+12
View File
@@ -97,6 +97,18 @@ const allNavGroups: NavGroup[] = [
index: 'finance', index: 'finance',
icon: Coin, icon: Coin,
children: [ 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/wallet-ledger', icon: Wallet, permission: 'wallet:view' },
{ label: '支付流水', to: '/admin/payments', icon: CreditCard, permission: 'wallet:view' }, { label: '支付流水', to: '/admin/payments', icon: CreditCard, permission: 'wallet:view' },
{ label: '提现审核', to: '/admin/withdrawals', icon: Money, permission: 'withdrawal:list' }, { label: '提现审核', to: '/admin/withdrawals', icon: Money, permission: 'withdrawal:list' },
+12
View File
@@ -64,6 +64,18 @@ export const adminRoutes: RouteRecordRaw[] = [
component: () => import('@/features/admin/views/AdminChatsView.vue'), component: () => import('@/features/admin/views/AdminChatsView.vue'),
meta: adminMeta, 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', path: '/admin/wallet-ledger',
name: 'admin-wallet-ledger', name: 'admin-wallet-ledger',
+3 -1
View File
@@ -1,3 +1,5 @@
import { formatMoneyWithSymbol } from '@/shared/utils/money'
export function useMoney() { export function useMoney() {
return (value: number | undefined | null) => `¥${Math.round(Number(value || 0))}` return (value: number | undefined | null) => formatMoneyWithSymbol(value)
} }
+1 -1
View File
@@ -9,7 +9,7 @@
* @example roundMoney(12.36) -> 12.4 * @example roundMoney(12.36) -> 12.4
*/ */
export function roundMoney(value: number): number { export function roundMoney(value: number): number {
return Math.round(value * 10) / 10 return Math.round(Number(value || 0) * 10) / 10
} }
/** /**
+4 -4
View File
@@ -46,13 +46,13 @@ export function getListingDisplayPrice(item: Listing) {
export function getListingRentPrice(item: Listing) { export function getListingRentPrice(item: Listing) {
const buyerCoinBasePrice = readPriceBreakdownNumber(item, 'buyer_coin_base_price') const buyerCoinBasePrice = readPriceBreakdownNumber(item, 'buyer_coin_base_price')
if (buyerCoinBasePrice > 0) return Math.round(buyerCoinBasePrice) if (buyerCoinBasePrice > 0) return roundMoney(buyerCoinBasePrice)
return Math.max(0, Math.round(getListingDisplayPrice(item) - getListingConsumablePrice(item))) return Math.max(0, roundMoney(getListingDisplayPrice(item) - getListingConsumablePrice(item)))
} }
export function getListingConsumablePrice(item: Listing) { export function getListingConsumablePrice(item: Listing) {
const consumablePrice = readPriceBreakdownNumber(item, 'consumable_price') 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) 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 : '', mode: typeof row.mode === 'string' ? row.mode : '',
amount: amount:
row.mode === '收费' row.mode === '收费'
? Math.round( ? roundMoney(
readUnknownNumber(row.quantity) * readUnknownNumber(row.quantity) *
readUnitPrice(typeof row.price === 'string' ? row.price : '') readUnitPrice(typeof row.price === 'string' ? row.price : '')
) )
+2 -2
View File
@@ -23,7 +23,7 @@ export const commonOnlineTimes = [
] ]
export function roundMoney(value: number) { export function roundMoney(value: number) {
return Math.round(value) return Math.round(value * 10) / 10
} }
export function roundRatio(value: number) { export function roundRatio(value: number) {
@@ -31,7 +31,7 @@ export function roundRatio(value: number) {
} }
export function formatNumber(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) { export function readUnitPrice(priceText: string) {