From 2c4e87475a1913c9124c528b6de5a6331d3b2a4f Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 8 Sep 2026 16:05:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(distribution):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E5=88=86=E9=94=80=E6=94=B6=E5=8F=B7=E8=B5=84=E4=BA=A7=E5=8F=8C?= =?UTF-8?q?=E8=A7=86=E5=9B=BE=E3=80=81=E5=95=86=E6=88=B7=E6=8F=90=E7=8E=B0?= =?UTF-8?q?=E3=80=81=E8=87=AA=E4=B8=BB=E5=8A=A0=E4=BB=B7=E3=80=81=E5=93=81?= =?UTF-8?q?=E7=89=8C=E8=A3=85=E4=BF=AE=E4=B8=8E=E6=95=B0=E6=8D=AE=E5=A4=A7?= =?UTF-8?q?=E7=9B=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/model/distribution.go | 6 + backend/internal/modules/distribution/dto.go | 149 +++++ .../internal/modules/distribution/handler.go | 213 ++++++ .../modules/distribution/repository.go | 609 +++++++++++++++++- .../modules/distribution/repository_test.go | 228 ++++++- .../internal/modules/distribution/service.go | 64 ++ backend/internal/router/router.go | 18 + .../000074_distribution_enhancement.sql | 113 ++++ distribution-admin/src/AppShell.tsx | 15 +- distribution-admin/src/api.ts | 177 +++++ distribution-admin/src/pages/Accounts.tsx | 145 ++++- distribution-admin/src/pages/Dashboard.tsx | 261 +++++++- distribution-admin/src/pages/Orders.tsx | 420 ++++++++++-- distribution-admin/src/pages/SiteSettings.tsx | 293 +++++++++ distribution-admin/src/pages/Wallet.tsx | 399 ++++++++++++ .../src/features/listings/views/HomeView.vue | 18 +- frontend/src/layouts/AppLayout.css | 9 + frontend/src/layouts/AppLayout.vue | 45 +- frontend/src/stores/distributionSite.ts | 71 +- 19 files changed, 3145 insertions(+), 108 deletions(-) create mode 100644 backend/migrations/000074_distribution_enhancement.sql create mode 100644 distribution-admin/src/pages/SiteSettings.tsx create mode 100644 distribution-admin/src/pages/Wallet.tsx diff --git a/backend/internal/model/distribution.go b/backend/internal/model/distribution.go index c449808..6011b1b 100644 --- a/backend/internal/model/distribution.go +++ b/backend/internal/model/distribution.go @@ -10,6 +10,11 @@ type DistributionSite struct { Domain string `gorm:"size:255;not null;uniqueIndex" json:"domain"` Status string `gorm:"size:16;not null;default:'active';index" json:"status"` DisplayAccountEnabled bool `gorm:"not null;default:true" json:"display_account_enabled"` + LogoURL string `gorm:"size:512;not null;default:''" json:"logo_url"` + FaviconURL string `gorm:"size:512;not null;default:''" json:"favicon_url"` + BannersJSON string `gorm:"type:text" json:"banners_json"` + AnnouncementsJSON string `gorm:"type:text" json:"announcements_json"` + FooterInfoJSON string `gorm:"type:text" json:"footer_info_json"` DefaultSupportGroupID *uint64 `gorm:"index" json:"default_support_group_id,omitempty"` TaxEnabled bool `gorm:"not null;default:false" json:"tax_enabled"` TaxRateBPS int64 `gorm:"not null;default:100" json:"tax_rate_bps"` @@ -40,6 +45,7 @@ type DistributionSiteAccount struct { AccountID uint64 `gorm:"not null;uniqueIndex:uk_distribution_site_account;index" json:"account_id"` ListingID uint64 `gorm:"not null;index" json:"listing_id"` DisplayStatus string `gorm:"size:16;not null;default:'enabled';index" json:"display_status"` + MarkupAmountCent int64 `gorm:"not null;default:0" json:"markup_amount_cent"` DisplayStartedAt *time.Time `json:"display_started_at,omitempty"` DisplayEndedAt *time.Time `json:"display_ended_at,omitempty"` CreatedAt time.Time `json:"created_at"` diff --git a/backend/internal/modules/distribution/dto.go b/backend/internal/modules/distribution/dto.go index 2fbdf0f..e549665 100644 --- a/backend/internal/modules/distribution/dto.go +++ b/backend/internal/modules/distribution/dto.go @@ -9,6 +9,11 @@ type SiteDTO struct { Domain string `json:"domain"` Status string `json:"status"` DisplayAccountEnabled bool `json:"display_account_enabled"` + LogoURL string `json:"logo_url"` + FaviconURL string `json:"favicon_url"` + BannersJSON string `json:"banners_json"` + AnnouncementsJSON string `json:"announcements_json"` + FooterInfoJSON string `json:"footer_info_json"` DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"` TaxEnabled bool `json:"tax_enabled"` TaxRateBPS int64 `json:"tax_rate_bps"` @@ -34,6 +39,11 @@ type UpdateSiteRequest struct { DefaultSupportGroupID *uint64 `json:"default_support_group_id"` TaxEnabled *bool `json:"tax_enabled"` TaxRateBPS *int64 `json:"tax_rate_bps"` + LogoURL *string `json:"logo_url"` + FaviconURL *string `json:"favicon_url"` + BannersJSON *string `json:"banners_json"` + AnnouncementsJSON *string `json:"announcements_json"` + FooterInfoJSON *string `json:"footer_info_json"` } type RuleDTO struct { @@ -126,6 +136,7 @@ type AccountDisplayDTO struct { AccountID uint64 `json:"account_id"` ListingID uint64 `json:"listing_id"` DisplayStatus string `json:"display_status"` + MarkupAmountCent int64 `json:"markup_amount_cent"` ListingNo string `json:"listing_no,omitempty"` Title string `json:"title,omitempty"` GameName string `json:"game_name,omitempty"` @@ -157,6 +168,7 @@ type PublicDistributionListingDTO struct { HafCoinAmount int64 `json:"haf_coin_amount"` PriceCent int64 `json:"price_cent"` DepositAmountCent int64 `json:"deposit_amount_cent"` + MarkupAmountCent int64 `json:"markup_amount_cent"` InTransaction bool `json:"in_transaction"` SiteCode string `json:"site_code"` SiteName string `json:"site_name"` @@ -175,6 +187,11 @@ type PublicSiteInfoDTO struct { Domain string `json:"domain"` Status string `json:"status"` DisplayAccountEnabled bool `json:"display_account_enabled"` + LogoURL string `json:"logo_url"` + FaviconURL string `json:"favicon_url"` + BannersJSON string `json:"banners_json"` + AnnouncementsJSON string `json:"announcements_json"` + FooterInfoJSON string `json:"footer_info_json"` DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"` } @@ -283,3 +300,135 @@ type OnboardSiteResponse struct { Member DistributionCurrentMemberDTO `json:"member"` } +// ReceivedListingDTO 是分销站点收到的账号资产信息 +type ReceivedListingDTO struct { + ListingID uint64 `json:"listing_id"` + ListingNo string `json:"listing_no"` + SiteID uint64 `json:"site_id"` + AccountID uint64 `json:"account_id"` + OwnerID uint64 `json:"owner_id"` + OwnerPhone string `json:"owner_phone"` + OwnerNickname string `json:"owner_nickname"` + Title string `json:"title"` + GameName string `json:"game_name"` + ServerRegion string `json:"server_region"` + LoginPlatform string `json:"login_platform"` + RankLevel string `json:"rank_level"` + HafCoinAmount int64 `json:"haf_coin_amount"` + PriceCent int64 `json:"price_cent"` + DepositAmountCent int64 `json:"deposit_amount_cent"` + Status string `json:"status"` + ReviewStatus string `json:"review_status"` + ReviewReason string `json:"review_reason"` + PublishedAt *time.Time `json:"published_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type ReceivedListingQuery struct { + SiteID uint64 + Status string + ReviewStatus string + GameName string + Keyword string + Page int + PageSize int + Size int +} + +type ReceivedListingPage struct { + Items []ReceivedListingDTO `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type UpdateReceivedListingStatusRequest struct { + Status string `json:"status" binding:"required,oneof=published offline"` +} + +// 站点品牌与装修 DTO +type UpdateSiteBrandingRequest struct { + LogoURL string `json:"logo_url"` + FaviconURL string `json:"favicon_url"` + BannersJSON string `json:"banners_json"` + AnnouncementsJSON string `json:"announcements_json"` + FooterInfoJSON string `json:"footer_info_json"` +} + +// 自主加价 DTO +type UpdateAccountMarkupRequest struct { + MarkupAmountCent int64 `json:"markup_amount_cent"` +} + +type BatchUpdateAccountMarkupRequest struct { + ListingIDs []uint64 `json:"listing_ids" binding:"required"` + MarkupAmountCent int64 `json:"markup_amount_cent"` +} + +// 商户钱包与提现 DTO +type SiteWalletDTO struct { + SiteID uint64 `json:"site_id"` + AvailableBalanceCent int64 `json:"available_balance_cent"` + FrozenBalanceCent int64 `json:"frozen_balance_cent"` + TotalSettledCent int64 `json:"total_settled_cent"` + TotalWithdrawnCent int64 `json:"total_withdrawn_cent"` +} + +type ApplySiteWithdrawalRequest struct { + SiteID uint64 `json:"site_id" binding:"required"` + AmountCent int64 `json:"amount_cent" binding:"required,gt=0"` + AccountType string `json:"account_type" binding:"required,oneof=alipay bank"` + AccountName string `json:"account_name" binding:"required"` + AccountNo string `json:"account_no" binding:"required"` + BankName string `json:"bank_name"` + BankBranch string `json:"bank_branch"` +} + +type SiteWithdrawalDTO struct { + ID uint64 `json:"id"` + WithdrawNo string `json:"withdraw_no"` + SiteID uint64 `json:"site_id"` + AmountCent int64 `json:"amount_cent"` + FeeCent int64 `json:"fee_cent"` + ActualAmountCent int64 `json:"actual_amount_cent"` + AccountType string `json:"account_type"` + AccountName string `json:"account_name"` + AccountNo string `json:"account_no"` + BankName string `json:"bank_name"` + BankBranch string `json:"bank_branch"` + Status string `json:"status"` + ReviewRemark string `json:"review_remark"` + PaymentProofURL string `json:"payment_proof_url"` + PaymentRemark string `json:"payment_remark"` + PaidAt *time.Time `json:"paid_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type SiteWithdrawalPage struct { + Items []SiteWithdrawalDTO `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// 运营大盘统计 DTO +type DistributionAnalyticsDTO struct { + TodayReceivedCount int64 `json:"today_received_count"` + TotalReceivedCount int64 `json:"total_received_count"` + ActiveListingCount int64 `json:"active_listing_count"` + TodayOrderCount int64 `json:"today_order_count"` + TodayGMVCent int64 `json:"today_gmv_cent"` + TodayProfitCent int64 `json:"today_profit_cent"` + MonthProfitCent int64 `json:"month_profit_cent"` + AvailableBalanceCent int64 `json:"available_balance_cent"` + TrendDays []DistributionTrendPoint `json:"trend_days"` +} + +type DistributionTrendPoint struct { + Date string `json:"date"` + ReceivedCount int64 `json:"received_count"` + OrderCount int64 `json:"order_count"` + GMVCent int64 `json:"gmv_cent"` + ProfitCent int64 `json:"profit_cent"` +} + diff --git a/backend/internal/modules/distribution/handler.go b/backend/internal/modules/distribution/handler.go index a62ba29..c26aee6 100644 --- a/backend/internal/modules/distribution/handler.go +++ b/backend/internal/modules/distribution/handler.go @@ -486,6 +486,219 @@ func (h *Handler) OnboardSite(c *gin.Context) { response.Created(c, resp) } +func (h *Handler) resolveSiteID(c *gin.Context) (uint64, error) { + adminID, _ := currentAdminID(c) + var requested uint64 + if raw := c.Param("id"); raw != "" { + if id, err := strconv.ParseUint(raw, 10, 64); err == nil && id > 0 { + requested = id + } + } + if requested == 0 { + if q := parseQueryUint(c, "site_id"); q > 0 { + requested = uint64(q) + } + } + return h.service.ScopeSiteID(c.Request.Context(), adminID, requested) +} + +func (h *Handler) UpdateSiteBranding(c *gin.Context) { + siteID, err := h.resolveSiteID(c) + if err != nil { + writeError(c, err) + return + } + if siteID == 0 { + response.BadRequest(c, "未指定分销站点") + return + } + var req UpdateSiteBrandingRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "品牌装修参数格式不正确") + return + } + adminID, _ := currentAdminID(c) + dto, err := h.service.UpdateSiteBranding(c.Request.Context(), adminID, siteID, req) + if err != nil { + writeError(c, err) + return + } + response.OK(c, dto) +} + +func (h *Handler) UpdateAccountMarkup(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req UpdateAccountMarkupRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "加价参数格式不正确") + return + } + adminID, _ := currentAdminID(c) + dto, err := h.service.UpdateAccountMarkup(c.Request.Context(), adminID, id, req.MarkupAmountCent) + if err != nil { + writeError(c, err) + return + } + response.OK(c, dto) +} + +func (h *Handler) BatchUpdateAccountMarkup(c *gin.Context) { + siteID, err := h.resolveSiteID(c) + if err != nil { + writeError(c, err) + return + } + var req BatchUpdateAccountMarkupRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "批量加价参数格式不正确") + return + } + if siteID == 0 { + response.BadRequest(c, "未指定分销站点") + return + } + adminID, _ := currentAdminID(c) + if err := h.service.BatchUpdateAccountMarkup(c.Request.Context(), adminID, siteID, req); err != nil { + writeError(c, err) + return + } + response.OK(c, gin.H{"updated": true}) +} + +func (h *Handler) ListReceivedListings(c *gin.Context) { + siteID, err := h.resolveSiteID(c) + if err != nil { + writeError(c, err) + return + } + if siteID == 0 { + response.BadRequest(c, "未指定分销站点") + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + q := ReceivedListingQuery{ + Page: page, + PageSize: pageSize, + Status: c.Query("status"), + ReviewStatus: c.Query("review_status"), + GameName: c.Query("game_name"), + Keyword: c.Query("keyword"), + } + adminID, _ := currentAdminID(c) + resp, err := h.service.ListReceivedListings(c.Request.Context(), adminID, siteID, q) + if err != nil { + writeError(c, err) + return + } + response.OK(c, resp) +} + +func (h *Handler) UpdateReceivedListingStatus(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req UpdateReceivedListingStatusRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "状态参数格式不正确") + return + } + adminID, _ := currentAdminID(c) + if err := h.service.UpdateReceivedListingStatus(c.Request.Context(), adminID, id, req.Status); err != nil { + writeError(c, err) + return + } + response.OK(c, gin.H{"updated": true}) +} + +func (h *Handler) GetSiteWallet(c *gin.Context) { + siteID, err := h.resolveSiteID(c) + if err != nil { + writeError(c, err) + return + } + if siteID == 0 { + response.BadRequest(c, "未指定分销站点") + return + } + adminID, _ := currentAdminID(c) + walletDTO, err := h.service.GetSiteWallet(c.Request.Context(), adminID, siteID) + if err != nil { + writeError(c, err) + return + } + response.OK(c, walletDTO) +} + +func (h *Handler) ApplySiteWithdrawal(c *gin.Context) { + siteID, err := h.resolveSiteID(c) + if err != nil { + writeError(c, err) + return + } + if siteID == 0 { + response.BadRequest(c, "未指定分销站点") + return + } + var req ApplySiteWithdrawalRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "提现申请参数格式不正确") + return + } + adminID, _ := currentAdminID(c) + dto, err := h.service.ApplySiteWithdrawal(c.Request.Context(), adminID, siteID, req) + if err != nil { + writeError(c, err) + return + } + response.Created(c, dto) +} + +func (h *Handler) ListSiteWithdrawals(c *gin.Context) { + siteID, err := h.resolveSiteID(c) + if err != nil { + writeError(c, err) + return + } + if siteID == 0 { + response.BadRequest(c, "未指定分销站点") + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + adminID, _ := currentAdminID(c) + pageResult, err := h.service.ListSiteWithdrawals(c.Request.Context(), adminID, siteID, page, pageSize) + if err != nil { + writeError(c, err) + return + } + response.OK(c, pageResult) +} + +func (h *Handler) GetDashboardAnalytics(c *gin.Context) { + siteID, err := h.resolveSiteID(c) + if err != nil { + writeError(c, err) + return + } + if siteID == 0 { + response.BadRequest(c, "未指定分销站点") + return + } + adminID, _ := currentAdminID(c) + analytics, err := h.service.GetDashboardAnalytics(c.Request.Context(), adminID, siteID) + if err != nil { + writeError(c, err) + return + } + response.OK(c, analytics) +} + + func isHTTPSRequest(c *gin.Context) bool { if c.Request.TLS != nil { return true diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index 1c98d2a..0ed6a08 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -2,6 +2,8 @@ package distribution import ( "context" + "crypto/rand" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -10,6 +12,7 @@ import ( "hfb_sys/backend/internal/auditlog" "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/wallet" "github.com/redis/go-redis/v9" "golang.org/x/crypto/bcrypt" @@ -187,6 +190,21 @@ func (r *Repository) UpdateSite(ctx context.Context, adminID, id uint64, req Upd if req.TaxRateBPS != nil { row.TaxRateBPS = *req.TaxRateBPS } + if req.LogoURL != nil { + row.LogoURL = strings.TrimSpace(*req.LogoURL) + } + if req.FaviconURL != nil { + row.FaviconURL = strings.TrimSpace(*req.FaviconURL) + } + if req.BannersJSON != nil { + row.BannersJSON = *req.BannersJSON + } + if req.AnnouncementsJSON != nil { + row.AnnouncementsJSON = *req.AnnouncementsJSON + } + if req.FooterInfoJSON != nil { + row.FooterInfoJSON = *req.FooterInfoJSON + } if err := r.db.WithContext(ctx).Save(&row).Error; err != nil { return nil, err } @@ -284,7 +302,24 @@ func (r *Repository) UpdateRuleStatus(ctx context.Context, adminID, id uint64, s } func siteDTO(row model.DistributionSite) SiteDTO { - return SiteDTO{ID: row.ID, SiteCode: row.SiteCode, SiteName: row.SiteName, Domain: row.Domain, Status: row.Status, DisplayAccountEnabled: row.DisplayAccountEnabled, DefaultSupportGroupID: row.DefaultSupportGroupID, TaxEnabled: row.TaxEnabled, TaxRateBPS: row.TaxRateBPS, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt} + return SiteDTO{ + ID: row.ID, + SiteCode: row.SiteCode, + SiteName: row.SiteName, + Domain: row.Domain, + Status: row.Status, + DisplayAccountEnabled: row.DisplayAccountEnabled, + DefaultSupportGroupID: row.DefaultSupportGroupID, + TaxEnabled: row.TaxEnabled, + TaxRateBPS: row.TaxRateBPS, + LogoURL: row.LogoURL, + FaviconURL: row.FaviconURL, + BannersJSON: row.BannersJSON, + AnnouncementsJSON: row.AnnouncementsJSON, + FooterInfoJSON: row.FooterInfoJSON, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } } func ruleDTO(row model.DistributionRuleVersion) RuleDTO { @@ -352,6 +387,7 @@ func (r *Repository) ListAccountDisplays(ctx context.Context, siteID uint64, sta AccountID uint64 `gorm:"column:account_id"` ListingID uint64 `gorm:"column:listing_id"` DisplayStatus string `gorm:"column:display_status"` + MarkupAmountCent int64 `gorm:"column:markup_amount_cent"` ListingNo string `gorm:"column:listing_no"` Title string `gorm:"column:title"` GameName string `gorm:"column:game_name"` @@ -374,6 +410,7 @@ func (r *Repository) ListAccountDisplays(ctx context.Context, siteID uint64, sta dsa.account_id, dsa.listing_id, dsa.display_status, + COALESCE(dsa.markup_amount_cent, 0) AS markup_amount_cent, COALESCE(l.listing_no, '') AS listing_no, COALESCE(ga.title, '') AS title, COALESCE(ga.game_name, '') AS game_name, @@ -404,6 +441,7 @@ func (r *Repository) ListAccountDisplays(ctx context.Context, siteID uint64, sta AccountID: row.AccountID, ListingID: row.ListingID, DisplayStatus: row.DisplayStatus, + MarkupAmountCent: row.MarkupAmountCent, ListingNo: row.ListingNo, Title: row.Title, GameName: row.GameName, @@ -678,6 +716,18 @@ func (r *Repository) MarkSettlementBatch(ctx context.Context, adminID, id uint64 Updates(map[string]any{"settlement_status": "settled", "settled_at": now}).Error; err != nil { return err } + if err := wallet.AppendEntries(tx, wallet.Entry{ + UserID: 0, + DistributionSiteID: row.SiteID, + Direction: "in", + AmountCent: row.TotalAmountCent, + BalanceType: "available", + BizType: "distribution_settlement", + BizNo: row.BatchNo, + Remark: fmt.Sprintf("分销结算批次入账 %s", row.BatchNo), + }); err != nil { + return err + } row.Status = "settled" row.SettledAt = &now return tx.Save(&row).Error @@ -1113,6 +1163,7 @@ func (r *Repository) ListPublicSiteListings(ctx context.Context, siteCode string HafCoinAmount int64 `gorm:"column:haf_coin_amount"` PriceCent int64 `gorm:"column:price_cent"` DepositAmountCent int64 `gorm:"column:deposit_amount_cent"` + MarkupAmountCent int64 `gorm:"column:markup_amount_cent"` InTransaction bool `gorm:"column:in_transaction"` } @@ -1139,8 +1190,9 @@ func (r *Repository) ListPublicSiteListings(ctx context.Context, siteCode string COALESCE(ga.login_platform, '') AS login_platform, COALESCE(ga.rank_level, '') AS rank_level, COALESCE(ga.haf_coin_amount, 0) AS haf_coin_amount, - COALESCE(l.price_cent, 0) AS price_cent, + COALESCE(l.price_cent, 0) + COALESCE(dsa.markup_amount_cent, 0) AS price_cent, COALESCE(l.deposit_amount_cent, 0) AS deposit_amount_cent, + COALESCE(dsa.markup_amount_cent, 0) AS markup_amount_cent, COALESCE(l.in_transaction, 0) AS in_transaction `). Order("dsa.id DESC"). @@ -1165,6 +1217,7 @@ func (r *Repository) ListPublicSiteListings(ctx context.Context, siteCode string HafCoinAmount: row.HafCoinAmount, PriceCent: row.PriceCent, DepositAmountCent: row.DepositAmountCent, + MarkupAmountCent: row.MarkupAmountCent, InTransaction: row.InTransaction, SiteCode: site.SiteCode, SiteName: site.SiteName, @@ -1211,5 +1264,557 @@ func (r *Repository) GetPublicSiteInfo(ctx context.Context, code, domain string) Status: site.Status, DisplayAccountEnabled: site.DisplayAccountEnabled, DefaultSupportGroupID: site.DefaultSupportGroupID, + LogoURL: site.LogoURL, + FaviconURL: site.FaviconURL, + BannersJSON: site.BannersJSON, + AnnouncementsJSON: site.AnnouncementsJSON, + FooterInfoJSON: site.FooterInfoJSON, + }, nil +} + +func generateWithdrawNo() (string, error) { + buf := make([]byte, 8) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return fmt.Sprintf("DW%d%s", time.Now().Unix(), hex.EncodeToString(buf)[:8]), nil +} + +func maskAccountNo(accountNo, accountType string) string { + length := len(accountNo) + if length <= 4 { + return accountNo + } + switch accountType { + case "alipay", "wechat": + if length == 11 { + return accountNo[:3] + "****" + accountNo[7:] + } + return accountNo[:2] + "****" + accountNo[length-2:] + case "bank": + if length > 8 { + return accountNo[:4] + "****" + accountNo[length-4:] + } + return accountNo[:2] + "****" + accountNo[length-2:] + } + return accountNo +} + +func (r *Repository) UpdateSiteBranding(ctx context.Context, adminID, siteID uint64, req UpdateSiteBrandingRequest) (*SiteDTO, error) { + if siteID == 0 { + return nil, ErrSiteNotFound + } + if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil { + return nil, err + } + var site model.DistributionSite + if err := r.db.WithContext(ctx).First(&site, siteID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrSiteNotFound + } + return nil, err + } + site.LogoURL = strings.TrimSpace(req.LogoURL) + site.FaviconURL = strings.TrimSpace(req.FaviconURL) + site.BannersJSON = strings.TrimSpace(req.BannersJSON) + site.AnnouncementsJSON = strings.TrimSpace(req.AnnouncementsJSON) + site.FooterInfoJSON = strings.TrimSpace(req.FooterInfoJSON) + if err := r.db.WithContext(ctx).Model(&site).Updates(map[string]any{ + "logo_url": site.LogoURL, + "favicon_url": site.FaviconURL, + "banners_json": site.BannersJSON, + "announcements_json": site.AnnouncementsJSON, + "footer_info_json": site.FooterInfoJSON, + }).Error; err != nil { + return nil, err + } + dto := siteDTO(site) + return &dto, nil +} + +func (r *Repository) UpdateAccountMarkup(ctx context.Context, adminID, id uint64, markupCent int64) (*AccountDisplayDTO, error) { + if markupCent < 0 { + return nil, ErrInvalidAmount + } + var row model.DistributionSiteAccount + if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrAccountDisplayNotFound + } + return nil, err + } + if err := r.ensureSiteAccess(ctx, adminID, row.SiteID); err != nil { + return nil, err + } + if err := r.db.WithContext(ctx).Model(&row).Update("markup_amount_cent", markupCent).Error; err != nil { + return nil, err + } + displays, err := r.ListAccountDisplays(ctx, row.SiteID, "") + if err != nil { + return nil, err + } + for _, d := range displays { + if d.ID == id { + return &d, nil + } + } + return nil, ErrAccountDisplayNotFound +} + +func (r *Repository) BatchUpdateAccountMarkup(ctx context.Context, adminID, siteID uint64, req BatchUpdateAccountMarkupRequest) error { + if siteID == 0 { + return ErrSiteNotFound + } + if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil { + return err + } + if req.MarkupAmountCent < 0 { + return ErrInvalidAmount + } + query := r.db.WithContext(ctx).Model(&model.DistributionSiteAccount{}).Where("site_id = ?", siteID) + if len(req.ListingIDs) > 0 { + query = query.Where("listing_id IN (?)", req.ListingIDs) + } + return query.Update("markup_amount_cent", req.MarkupAmountCent).Error +} + +func (r *Repository) ListReceivedListings(ctx context.Context, adminID, siteID uint64, q ReceivedListingQuery) (*ReceivedListingPage, error) { + if siteID == 0 { + return nil, ErrSiteNotFound + } + if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil { + return nil, err + } + page := q.Page + size := q.PageSize + if size <= 0 && q.Size > 0 { + size = q.Size + } + if page < 1 { + page = 1 + } + if size < 1 { + size = 20 + } + if size > 100 { + size = 100 + } + + base := r.db.WithContext(ctx).Table("rental_listings rl"). + Joins("JOIN game_accounts ga ON ga.id = rl.account_id"). + Joins("LEFT JOIN users u ON u.id = rl.owner_id"). + Where("rl.distribution_site_id = ?", siteID) + + if q.ReviewStatus != "" { + base = base.Where("rl.review_status = ?", q.ReviewStatus) + } + if q.Status != "" { + base = base.Where("rl.status = ?", q.Status) + } + if q.GameName != "" { + base = base.Where("ga.game_name = ?", q.GameName) + } + if q.Keyword != "" { + kw := "%" + strings.TrimSpace(q.Keyword) + "%" + base = base.Where("(rl.listing_no LIKE ? OR ga.title LIKE ? OR u.phone LIKE ? OR u.nickname LIKE ?)", kw, kw, kw, kw) + } + + var total int64 + if err := base.Session(&gorm.Session{}).Select("rl.id").Count(&total).Error; err != nil { + return nil, err + } + + type receivedListingRow struct { + ID uint64 `gorm:"column:id"` + ListingNo string `gorm:"column:listing_no"` + AccountID uint64 `gorm:"column:account_id"` + OwnerID uint64 `gorm:"column:owner_id"` + OwnerUsername string `gorm:"column:owner_username"` + OwnerPhone string `gorm:"column:owner_phone"` + GameName string `gorm:"column:game_name"` + Title string `gorm:"column:title"` + ServerRegion string `gorm:"column:server_region"` + LoginPlatform string `gorm:"column:login_platform"` + RankLevel string `gorm:"column:rank_level"` + HafCoinAmount int64 `gorm:"column:haf_coin_amount"` + PriceCent int64 `gorm:"column:price_cent"` + DepositAmountCent int64 `gorm:"column:deposit_amount_cent"` + Status string `gorm:"column:status"` + ReviewStatus string `gorm:"column:review_status"` + ReviewReason string `gorm:"column:review_reason"` + InTransaction bool `gorm:"column:in_transaction"` + DistributionSiteID uint64 `gorm:"column:distribution_site_id"` + CreatedAt time.Time `gorm:"column:created_at"` + PublishedAt *time.Time `gorm:"column:published_at"` + } + + var rows []receivedListingRow + selectSQL := ` + rl.id, rl.listing_no, rl.account_id, rl.owner_id, + COALESCE(u.nickname, '') AS owner_username, + COALESCE(u.phone, '') AS owner_phone, + ga.game_name, ga.title, ga.server_region, ga.login_platform, ga.rank_level, ga.haf_coin_amount, + rl.price_cent, rl.deposit_amount_cent, rl.status, rl.review_status, rl.review_reason, + rl.in_transaction, COALESCE(rl.distribution_site_id, 0) AS distribution_site_id, + rl.created_at, rl.published_at + ` + if err := base.Select(selectSQL).Order("rl.id DESC").Offset((page - 1) * size).Limit(size).Scan(&rows).Error; err != nil { + return nil, err + } + + items := make([]ReceivedListingDTO, 0, len(rows)) + for _, r := range rows { + maskedPhone := r.OwnerPhone + if len(maskedPhone) >= 7 { + maskedPhone = maskedPhone[:3] + "****" + maskedPhone[len(maskedPhone)-4:] + } + items = append(items, ReceivedListingDTO{ + ListingID: r.ID, + ListingNo: r.ListingNo, + SiteID: r.DistributionSiteID, + AccountID: r.AccountID, + OwnerID: r.OwnerID, + OwnerPhone: maskedPhone, + OwnerNickname: r.OwnerUsername, + Title: r.Title, + GameName: r.GameName, + ServerRegion: r.ServerRegion, + LoginPlatform: r.LoginPlatform, + RankLevel: r.RankLevel, + HafCoinAmount: r.HafCoinAmount, + PriceCent: r.PriceCent, + DepositAmountCent: r.DepositAmountCent, + Status: r.Status, + ReviewStatus: r.ReviewStatus, + ReviewReason: r.ReviewReason, + PublishedAt: r.PublishedAt, + CreatedAt: r.CreatedAt, + }) + } + + return &ReceivedListingPage{ + Items: items, + Total: total, + Page: page, + PageSize: size, + }, nil +} + +func (r *Repository) UpdateReceivedListingStatus(ctx context.Context, adminID, listingID uint64, status string) error { + if listingID == 0 { + return errors.New("invalid listing id") + } + var listing model.RentalListing + if err := r.db.WithContext(ctx).First(&listing, listingID).Error; err != nil { + return err + } + if listing.DistributionSiteID == nil || *listing.DistributionSiteID == 0 { + return ErrSiteAccessDenied + } + if err := r.ensureSiteAccess(ctx, adminID, *listing.DistributionSiteID); err != nil { + return err + } + if status != "published" && status != "inactive" && status != "closed" { + return errors.New("invalid listing status") + } + return r.db.WithContext(ctx).Model(&listing).Update("status", status).Error +} + +func (r *Repository) GetSiteWallet(ctx context.Context, adminID, siteID uint64) (*SiteWalletDTO, error) { + if siteID == 0 { + return nil, ErrSiteNotFound + } + if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil { + return nil, err + } + var account model.WalletAccount + err := r.db.WithContext(ctx).Where("user_id = 0 AND distribution_site_id = ?", siteID).First(&account).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + var totalSettled int64 + _ = r.db.WithContext(ctx).Table("distribution_settlement_items dsi"). + Joins("JOIN distribution_settlement_batches dsb ON dsb.id = dsi.batch_id"). + Where("dsb.site_id = ? AND dsi.status = 'settled'", siteID). + Select("COALESCE(SUM(dsi.amount_cent), 0)").Scan(&totalSettled).Error + + var totalWithdrawn int64 + _ = r.db.WithContext(ctx).Model(&model.WithdrawalRequest{}). + Where("user_id = 0 AND distribution_site_id = ? AND status = 'completed'", siteID). + Select("COALESCE(SUM(amount_cent), 0)").Scan(&totalWithdrawn).Error + + return &SiteWalletDTO{ + SiteID: siteID, + AvailableBalanceCent: account.AvailableBalanceCent, + FrozenBalanceCent: account.FrozenBalanceCent, + TotalSettledCent: totalSettled, + TotalWithdrawnCent: totalWithdrawn, + }, nil +} + +func (r *Repository) ApplySiteWithdrawal(ctx context.Context, adminID, siteID uint64, req ApplySiteWithdrawalRequest) (*SiteWithdrawalDTO, error) { + if siteID == 0 { + return nil, ErrSiteNotFound + } + if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil { + return nil, err + } + if req.AmountCent <= 0 { + return nil, ErrInvalidAmount + } + accountType := strings.TrimSpace(req.AccountType) + accountName := strings.TrimSpace(req.AccountName) + accountNo := strings.TrimSpace(req.AccountNo) + if accountType == "" || accountName == "" || accountNo == "" { + return nil, errors.New("收款账户信息不完整") + } + + withdrawNo, err := generateWithdrawNo() + if err != nil { + return nil, err + } + + var withdrawal model.WithdrawalRequest + err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var account model.WalletAccount + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("user_id = 0 AND distribution_site_id = ?", siteID).First(&account).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("钱包账户不存在或余额不足") + } + return err + } + if account.AvailableBalanceCent < req.AmountCent { + return errors.New("可提现余额不足") + } + + withdrawal = model.WithdrawalRequest{ + WithdrawNo: withdrawNo, + UserID: 0, + DistributionSiteID: siteID, + AmountCent: req.AmountCent, + FeeCent: 0, + ActualAmountCent: req.AmountCent, + AccountType: accountType, + AccountName: accountName, + AccountNo: accountNo, + BankName: strings.TrimSpace(req.BankName), + BankBranch: strings.TrimSpace(req.BankBranch), + Status: "pending", + } + if err := tx.Create(&withdrawal).Error; err != nil { + return err + } + + return wallet.AppendEntries(tx, + wallet.Entry{ + UserID: 0, + DistributionSiteID: siteID, + Direction: "out", + AmountCent: req.AmountCent, + BalanceType: "available", + BizType: "withdraw_freeze", + BizNo: withdrawNo, + Remark: "分销提现申请冻结", + }, + wallet.Entry{ + UserID: 0, + DistributionSiteID: siteID, + Direction: "in", + AmountCent: req.AmountCent, + BalanceType: "frozen", + BizType: "withdraw_freeze", + BizNo: withdrawNo, + Remark: "分销提现申请冻结", + }, + ) + }) + if err != nil { + return nil, err + } + + return &SiteWithdrawalDTO{ + ID: withdrawal.ID, + WithdrawNo: withdrawal.WithdrawNo, + SiteID: withdrawal.DistributionSiteID, + AmountCent: withdrawal.AmountCent, + FeeCent: withdrawal.FeeCent, + ActualAmountCent: withdrawal.ActualAmountCent, + AccountType: withdrawal.AccountType, + AccountName: withdrawal.AccountName, + AccountNo: maskAccountNo(withdrawal.AccountNo, withdrawal.AccountType), + BankName: withdrawal.BankName, + BankBranch: withdrawal.BankBranch, + Status: withdrawal.Status, + CreatedAt: withdrawal.CreatedAt, + }, nil +} + +func (r *Repository) ListSiteWithdrawals(ctx context.Context, adminID, siteID uint64, page, size int) (*SiteWithdrawalPage, error) { + if siteID == 0 { + return nil, ErrSiteNotFound + } + if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil { + return nil, err + } + if page < 1 { + page = 1 + } + if size < 1 { + size = 20 + } + if size > 100 { + size = 100 + } + + query := r.db.WithContext(ctx).Model(&model.WithdrawalRequest{}). + Where("user_id = 0 AND distribution_site_id = ?", siteID) + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, err + } + + var rows []model.WithdrawalRequest + if err := query.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil { + return nil, err + } + + items := make([]SiteWithdrawalDTO, 0, len(rows)) + for _, w := range rows { + items = append(items, SiteWithdrawalDTO{ + ID: w.ID, + WithdrawNo: w.WithdrawNo, + SiteID: w.DistributionSiteID, + AmountCent: w.AmountCent, + FeeCent: w.FeeCent, + ActualAmountCent: w.ActualAmountCent, + AccountType: w.AccountType, + AccountName: w.AccountName, + AccountNo: maskAccountNo(w.AccountNo, w.AccountType), + BankName: w.BankName, + BankBranch: w.BankBranch, + Status: w.Status, + ReviewRemark: w.ReviewRemark, + PaymentProofURL: w.PaymentProofURL, + PaymentRemark: w.PaymentRemark, + PaidAt: w.PaidAt, + CreatedAt: w.CreatedAt, + }) + } + + return &SiteWithdrawalPage{ + Items: items, + Total: total, + Page: page, + PageSize: size, + }, nil +} + +func (r *Repository) GetDashboardAnalytics(ctx context.Context, adminID, siteID uint64) (*DistributionAnalyticsDTO, error) { + if siteID == 0 { + return nil, ErrSiteNotFound + } + if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil { + return nil, err + } + + todayStr := time.Now().Format("2006-01-02") + monthStr := time.Now().Format("2006-01") + + var todayReceived int64 + _ = r.db.WithContext(ctx).Model(&model.RentalListing{}). + Where("distribution_site_id = ? AND created_at >= ?", siteID, todayStr+" 00:00:00").Count(&todayReceived).Error + + var totalReceived int64 + _ = r.db.WithContext(ctx).Model(&model.RentalListing{}). + Where("distribution_site_id = ?", siteID).Count(&totalReceived).Error + + var activeListings int64 + _ = r.db.WithContext(ctx).Model(&model.RentalListing{}). + Where("distribution_site_id = ? AND status = 'published' AND review_status = 'approved'", siteID).Count(&activeListings).Error + + var todayOrders int64 + _ = r.db.WithContext(ctx).Model(&model.RentalOrder{}). + Where("distribution_site_id = ? AND created_at >= ?", siteID, todayStr+" 00:00:00").Count(&todayOrders).Error + + var todayGMV int64 + _ = r.db.WithContext(ctx).Table("rental_orders ro"). + Joins("JOIN distribution_order_financials f ON f.order_id = ro.id"). + Where("ro.distribution_site_id = ? AND ro.created_at >= ?", siteID, todayStr+" 00:00:00"). + Select("COALESCE(SUM(f.gross_paid_amount_cent), 0)").Scan(&todayGMV).Error + + var todayProfit int64 + _ = r.db.WithContext(ctx).Model(&model.DistributionOrderParty{}). + Where("site_id = ? AND created_at >= ?", siteID, todayStr+" 00:00:00"). + Select("COALESCE(SUM(profit_amount_cent), 0)").Scan(&todayProfit).Error + + var monthProfit int64 + _ = r.db.WithContext(ctx).Model(&model.DistributionOrderParty{}). + Where("site_id = ? AND created_at >= ?", siteID, monthStr+"-01 00:00:00"). + Select("COALESCE(SUM(profit_amount_cent), 0)").Scan(&monthProfit).Error + + var wallet model.WalletAccount + _ = r.db.WithContext(ctx).Where("user_id = 0 AND distribution_site_id = ?", siteID).First(&wallet).Error + + trendDays := make([]DistributionTrendPoint, 0, 7) + type trendRow struct { + DateStr string `gorm:"column:date_str"` + Received int64 `gorm:"column:received"` + Orders int64 `gorm:"column:orders"` + GMV int64 `gorm:"column:gmv"` + Profit int64 `gorm:"column:profit"` + } + var tRows []trendRow + startDate := time.Now().AddDate(0, 0, -6).Format("2006-01-02") + dateFormatExpr := "DATE_FORMAT(ro.created_at, '%Y-%m-%d')" + if r.db != nil && r.db.Dialector != nil && r.db.Dialector.Name() == "sqlite" { + dateFormatExpr = "strftime('%Y-%m-%d', ro.created_at)" + } + selectTrendSQL := fmt.Sprintf("%s AS date_str, COUNT(ro.id) AS orders, COALESCE(SUM(f.gross_paid_amount_cent), 0) AS gmv", dateFormatExpr) + _ = r.db.WithContext(ctx).Table("rental_orders ro"). + Joins("JOIN distribution_order_financials f ON f.order_id = ro.id"). + Where("ro.distribution_site_id = ? AND ro.created_at >= ?", siteID, startDate+" 00:00:00"). + Select(selectTrendSQL). + Group(dateFormatExpr). + Order("date_str ASC"). + Scan(&tRows).Error + + tRowMap := make(map[string]trendRow, len(tRows)) + for _, tr := range tRows { + tRowMap[tr.DateStr] = tr + } + for i := -6; i <= 0; i++ { + dStr := time.Now().AddDate(0, 0, i).Format("2006-01-02") + if tr, ok := tRowMap[dStr]; ok { + trendDays = append(trendDays, DistributionTrendPoint{ + Date: dStr, + ReceivedCount: tr.Received, + OrderCount: tr.Orders, + GMVCent: tr.GMV, + ProfitCent: tr.Profit, + }) + } else { + trendDays = append(trendDays, DistributionTrendPoint{ + Date: dStr, + ReceivedCount: 0, + OrderCount: 0, + GMVCent: 0, + ProfitCent: 0, + }) + } + } + + return &DistributionAnalyticsDTO{ + TodayReceivedCount: todayReceived, + TotalReceivedCount: totalReceived, + ActiveListingCount: activeListings, + TodayOrderCount: todayOrders, + TodayGMVCent: todayGMV, + TodayProfitCent: todayProfit, + MonthProfitCent: monthProfit, + AvailableBalanceCent: wallet.AvailableBalanceCent, + TrendDays: trendDays, }, nil } diff --git a/backend/internal/modules/distribution/repository_test.go b/backend/internal/modules/distribution/repository_test.go index a569c93..4c34607 100644 --- a/backend/internal/modules/distribution/repository_test.go +++ b/backend/internal/modules/distribution/repository_test.go @@ -23,7 +23,8 @@ func distributionTestDB(t *testing.T) *gorm.DB { &model.DistributionSiteMember{}, &model.DistributionRuleVersion{}, &model.DistributionOrderFinancial{}, &model.DistributionOrderParty{}, &model.DistributionSiteAccount{}, &model.DistributionSitePaymentChannel{}, &model.DistributionSettlementBatch{}, &model.DistributionSettlementItem{}, - &model.RentalListing{}, &model.GameAccount{}, + &model.RentalListing{}, &model.GameAccount{}, &model.User{}, + &model.WalletAccount{}, &model.WalletLedger{}, &model.WithdrawalRequest{}, ); err != nil { t.Fatal(err) } @@ -390,3 +391,228 @@ func TestGetPublicSiteInfo(t *testing.T) { } } +func TestDistributionBrandingAndMarkup(t *testing.T) { + db := distributionTestDB(t) + repo := NewRepository(db) + + site := model.DistributionSite{ + SiteCode: "brand_site", + SiteName: "品牌测试站", + Domain: "brand.example.com", + Status: "active", + DisplayAccountEnabled: true, + } + if err := db.Create(&site).Error; err != nil { + t.Fatal(err) + } + + // 1. Update branding + updated, err := repo.UpdateSiteBranding(t.Context(), 0, site.ID, UpdateSiteBrandingRequest{ + LogoURL: "https://cdn.example.com/logo.png", + FaviconURL: "https://cdn.example.com/favicon.ico", + BannersJSON: `[{"image_url":"https://cdn.example.com/b1.jpg"}]`, + AnnouncementsJSON: `[{"title":"开业大吉"}]`, + FooterInfoJSON: `{"copyright":"2026 Brand"}`, + }) + if err != nil { + t.Fatalf("UpdateSiteBranding failed: %v", err) + } + if updated.LogoURL != "https://cdn.example.com/logo.png" || updated.FaviconURL != "https://cdn.example.com/favicon.ico" { + t.Fatalf("unexpected branding: %+v", updated) + } + + // Verify PublicSiteInfo contains branding + pubInfo, err := repo.GetPublicSiteInfo(t.Context(), "brand_site", "") + if err != nil { + t.Fatalf("GetPublicSiteInfo failed: %v", err) + } + if pubInfo.LogoURL != "https://cdn.example.com/logo.png" { + t.Fatalf("expected LogoURL, got: %+v", pubInfo) + } + + // 2. Account Markup + dsa := model.DistributionSiteAccount{ + SiteID: site.ID, + AccountID: 101, + ListingID: 201, + DisplayStatus: "enabled", + } + if err := db.Create(&dsa).Error; err != nil { + t.Fatal(err) + } + + display, err := repo.UpdateAccountMarkup(t.Context(), 0, dsa.ID, 500) + if err != nil { + t.Fatalf("UpdateAccountMarkup failed: %v", err) + } + if display.MarkupAmountCent != 500 { + t.Fatalf("expected markup 500, got %d", display.MarkupAmountCent) + } + + // Batch update markup + if err := repo.BatchUpdateAccountMarkup(t.Context(), 0, site.ID, BatchUpdateAccountMarkupRequest{ + ListingIDs: []uint64{201}, + MarkupAmountCent: 800, + }); err != nil { + t.Fatalf("BatchUpdateAccountMarkup failed: %v", err) + } + + displays, err := repo.ListAccountDisplays(t.Context(), site.ID, "") + if err != nil || len(displays) == 0 || displays[0].MarkupAmountCent != 800 { + t.Fatalf("expected markup 800 after batch, got: %+v", displays) + } +} + +func TestDistributionReceivedListingsAndWallet(t *testing.T) { + db := distributionTestDB(t) + repo := NewRepository(db) + + site := model.DistributionSite{ + SiteCode: "recv_site", + SiteName: "收号测试站", + Domain: "recv.example.com", + Status: "active", + DisplayAccountEnabled: true, + } + if err := db.Create(&site).Error; err != nil { + t.Fatal(err) + } + + user := model.User{ + Phone: "13800001234", + Nickname: "seller01", + } + if err := db.Create(&user).Error; err != nil { + t.Fatal(err) + } + + ga := model.GameAccount{ + OwnerID: user.ID, + GameName: "delta_force", + ServerRegion: "cn", + LoginPlatform: "pc", + Title: "满配三角洲账号", + } + if err := db.Create(&ga).Error; err != nil { + t.Fatal(err) + } + + listing := model.RentalListing{ + ListingNo: "RL-99001", + AccountID: ga.ID, + OwnerID: user.ID, + PriceCent: 2000, + DepositAmountCent: 5000, + Status: "published", + ReviewStatus: "approved", + DistributionSiteID: &site.ID, + } + if err := db.Create(&listing).Error; err != nil { + t.Fatal(err) + } + + // 1. List received listings + recPage, err := repo.ListReceivedListings(t.Context(), 0, site.ID, ReceivedListingQuery{ + Page: 1, + PageSize: 10, + }) + if err != nil { + t.Fatalf("ListReceivedListings failed: %v", err) + } + if recPage.Total != 1 || len(recPage.Items) != 1 { + t.Fatalf("expected 1 received listing, got %d", recPage.Total) + } + item := recPage.Items[0] + if item.ListingNo != "RL-99001" || item.GameName != "delta_force" || item.OwnerNickname != "seller01" { + t.Fatalf("unexpected received listing: %+v", item) + } + + // Update received listing status + if err := repo.UpdateReceivedListingStatus(t.Context(), 0, listing.ID, "inactive"); err != nil { + t.Fatalf("UpdateReceivedListingStatus failed: %v", err) + } + + // 2. Settlement batch settles and credits wallet + rule := model.DistributionRuleVersion{SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "active", EffectiveFrom: time.Now()} + if err := db.Create(&rule).Error; err != nil { + t.Fatal(err) + } + batch := model.DistributionSettlementBatch{ + SiteID: site.ID, + BatchNo: "B-WALLET-01", + PeriodStart: time.Now().Add(-24 * time.Hour), + PeriodEnd: time.Now(), + TotalAmountCent: 10000, + Status: "pending", + } + if err := db.Create(&batch).Error; err != nil { + t.Fatal(err) + } + batchItem := model.DistributionSettlementItem{ + BatchID: batch.ID, + OrderID: 888, + PartyID: site.ID, + AmountCent: 10000, + Status: "pending", + } + if err := db.Create(&batchItem).Error; err != nil { + t.Fatal(err) + } + + // Mark batch settled -> auto-credits merchant wallet + if _, err := repo.MarkSettlementBatch(t.Context(), 0, batch.ID); err != nil { + t.Fatalf("MarkSettlementBatch failed: %v", err) + } + + walletDTO, err := repo.GetSiteWallet(t.Context(), 0, site.ID) + if err != nil { + t.Fatalf("GetSiteWallet failed: %v", err) + } + if walletDTO.AvailableBalanceCent != 10000 || walletDTO.TotalSettledCent != 10000 { + t.Fatalf("expected available balance 10000, got: %+v", walletDTO) + } + + // 3. Apply withdrawal + wd, err := repo.ApplySiteWithdrawal(t.Context(), 0, site.ID, ApplySiteWithdrawalRequest{ + SiteID: site.ID, + AmountCent: 4000, + AccountType: "alipay", + AccountName: "分销商李四", + AccountNo: "13800005678", + }) + if err != nil { + t.Fatalf("ApplySiteWithdrawal failed: %v", err) + } + if wd.AmountCent != 4000 || wd.Status != "pending" { + t.Fatalf("unexpected withdrawal: %+v", wd) + } + + // Check balance after freeze + walletAfterWD, err := repo.GetSiteWallet(t.Context(), 0, site.ID) + if err != nil { + t.Fatalf("GetSiteWallet after WD failed: %v", err) + } + if walletAfterWD.AvailableBalanceCent != 6000 || walletAfterWD.FrozenBalanceCent != 4000 { + t.Fatalf("expected available 6000 frozen 4000, got: %+v", walletAfterWD) + } + + // 4. List withdrawals + wdPage, err := repo.ListSiteWithdrawals(t.Context(), 0, site.ID, 1, 10) + if err != nil { + t.Fatalf("ListSiteWithdrawals failed: %v", err) + } + if wdPage.Total != 1 || len(wdPage.Items) != 1 { + t.Fatalf("expected 1 withdrawal record, got %d", wdPage.Total) + } + + // 5. Dashboard analytics + analytics, err := repo.GetDashboardAnalytics(t.Context(), 0, site.ID) + if err != nil { + t.Fatalf("GetDashboardAnalytics failed: %v", err) + } + if analytics.TotalReceivedCount != 1 || analytics.AvailableBalanceCent != 6000 { + t.Fatalf("unexpected analytics: %+v", analytics) + } +} + + diff --git a/backend/internal/modules/distribution/service.go b/backend/internal/modules/distribution/service.go index 6a852f7..9e77d75 100644 --- a/backend/internal/modules/distribution/service.go +++ b/backend/internal/modules/distribution/service.go @@ -239,4 +239,68 @@ func (s *Service) GetPublicSiteInfo(ctx context.Context, code, domain string) (* return s.repo.GetPublicSiteInfo(ctx, code, domain) } +func (s *Service) UpdateSiteBranding(ctx context.Context, adminID, siteID uint64, req UpdateSiteBrandingRequest) (*SiteDTO, error) { + if err := s.available(); err != nil { + return nil, err + } + return s.repo.UpdateSiteBranding(ctx, adminID, siteID, req) +} + +func (s *Service) UpdateAccountMarkup(ctx context.Context, adminID, id uint64, markupCent int64) (*AccountDisplayDTO, error) { + if err := s.available(); err != nil { + return nil, err + } + return s.repo.UpdateAccountMarkup(ctx, adminID, id, markupCent) +} + +func (s *Service) BatchUpdateAccountMarkup(ctx context.Context, adminID, siteID uint64, req BatchUpdateAccountMarkupRequest) error { + if err := s.available(); err != nil { + return err + } + return s.repo.BatchUpdateAccountMarkup(ctx, adminID, siteID, req) +} + +func (s *Service) ListReceivedListings(ctx context.Context, adminID, siteID uint64, q ReceivedListingQuery) (*ReceivedListingPage, error) { + if err := s.available(); err != nil { + return nil, err + } + return s.repo.ListReceivedListings(ctx, adminID, siteID, q) +} + +func (s *Service) UpdateReceivedListingStatus(ctx context.Context, adminID, listingID uint64, status string) error { + if err := s.available(); err != nil { + return err + } + return s.repo.UpdateReceivedListingStatus(ctx, adminID, listingID, status) +} + +func (s *Service) GetSiteWallet(ctx context.Context, adminID, siteID uint64) (*SiteWalletDTO, error) { + if err := s.available(); err != nil { + return nil, err + } + return s.repo.GetSiteWallet(ctx, adminID, siteID) +} + +func (s *Service) ApplySiteWithdrawal(ctx context.Context, adminID, siteID uint64, req ApplySiteWithdrawalRequest) (*SiteWithdrawalDTO, error) { + if err := s.available(); err != nil { + return nil, err + } + return s.repo.ApplySiteWithdrawal(ctx, adminID, siteID, req) +} + +func (s *Service) ListSiteWithdrawals(ctx context.Context, adminID, siteID uint64, page, size int) (*SiteWithdrawalPage, error) { + if err := s.available(); err != nil { + return nil, err + } + return s.repo.ListSiteWithdrawals(ctx, adminID, siteID, page, size) +} + +func (s *Service) GetDashboardAnalytics(ctx context.Context, adminID, siteID uint64) (*DistributionAnalyticsDTO, error) { + if err := s.available(); err != nil { + return nil, err + } + return s.repo.GetDashboardAnalytics(ctx, adminID, siteID) +} + + diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 4f0a963..b8c8256 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -608,6 +608,15 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { distributionProtectedRoutes.GET("/auth/me", distributionHandler.Me) distributionProtectedRoutes.POST("/auth/logout", distributionHandler.Logout) distributionProtectedRoutes.POST("/sites/:id/sync-accounts", distributionHandler.SyncSiteAccounts) + distributionProtectedRoutes.PUT("/sites/:id/branding", distributionHandler.UpdateSiteBranding) + distributionProtectedRoutes.GET("/sites/:id/wallet", distributionHandler.GetSiteWallet) + distributionProtectedRoutes.POST("/sites/:id/withdrawals", distributionHandler.ApplySiteWithdrawal) + distributionProtectedRoutes.GET("/sites/:id/withdrawals", distributionHandler.ListSiteWithdrawals) + distributionProtectedRoutes.GET("/sites/:id/analytics", distributionHandler.GetDashboardAnalytics) + distributionProtectedRoutes.GET("/received-listings", distributionHandler.ListReceivedListings) + distributionProtectedRoutes.PUT("/received-listings/:id/status", distributionHandler.UpdateReceivedListingStatus) + distributionProtectedRoutes.PUT("/accounts/:id/markup", distributionHandler.UpdateAccountMarkup) + distributionProtectedRoutes.POST("/accounts/batch-markup", distributionHandler.BatchUpdateAccountMarkup) } adminRoutes := api.Group("/admin", requireAdmin, middleware.RequireAdminPasswordChanged()) @@ -625,6 +634,13 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.POST("/distribution/sites/onboard", requirePerm("distribution:site_manage"), distributionHandler.OnboardSite) adminRoutes.POST("/distribution/sites/:id/sync-accounts", requirePerm("distribution:account_display"), distributionHandler.SyncSiteAccounts) adminRoutes.PUT("/distribution/sites/:id", requirePerm("distribution:site_manage"), distributionHandler.UpdateSite) + adminRoutes.PUT("/distribution/sites/:id/branding", requirePerm("distribution:site_manage"), distributionHandler.UpdateSiteBranding) + adminRoutes.GET("/distribution/sites/:id/wallet", requirePerm("distribution:settlement_view"), distributionHandler.GetSiteWallet) + adminRoutes.POST("/distribution/sites/:id/withdrawals", requirePerm("distribution:settlement_manage"), distributionHandler.ApplySiteWithdrawal) + adminRoutes.GET("/distribution/sites/:id/withdrawals", requirePerm("distribution:settlement_view"), distributionHandler.ListSiteWithdrawals) + adminRoutes.GET("/distribution/sites/:id/analytics", requirePerm("distribution:site_view"), distributionHandler.GetDashboardAnalytics) + adminRoutes.GET("/distribution/received-listings", requirePerm("distribution:account_view"), distributionHandler.ListReceivedListings) + adminRoutes.PUT("/distribution/received-listings/:id/status", requirePerm("distribution:account_display"), distributionHandler.UpdateReceivedListingStatus) adminRoutes.GET("/distribution/rules", requirePerm("distribution:rule_view"), distributionHandler.ListRules) adminRoutes.POST("/distribution/rules", requirePerm("distribution:rule_manage"), distributionHandler.CreateRule) adminRoutes.PUT("/distribution/rules/:id/status", requirePerm("distribution:rule_manage"), distributionHandler.UpdateRuleStatus) @@ -633,6 +649,8 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.POST("/distribution/orders/:id/financial-snapshot", requirePerm("distribution:settlement_manage"), distributionHandler.CreateFinancialSnapshot) adminRoutes.GET("/distribution/accounts", requirePerm("distribution:account_view"), distributionHandler.ListAccountDisplays) adminRoutes.PUT("/distribution/accounts/:id/display", requirePerm("distribution:account_display"), distributionHandler.UpdateAccountDisplay) + adminRoutes.PUT("/distribution/accounts/:id/markup", requirePerm("distribution:account_display"), distributionHandler.UpdateAccountMarkup) + adminRoutes.POST("/distribution/accounts/batch-markup", requirePerm("distribution:account_display"), distributionHandler.BatchUpdateAccountMarkup) adminRoutes.GET("/distribution/payment-channels", requirePerm("distribution:payment_view"), distributionHandler.ListPaymentChannels) adminRoutes.POST("/distribution/payment-channels", requirePerm("distribution:payment_manage"), distributionHandler.BindPaymentChannel) adminRoutes.DELETE("/distribution/payment-channels/:id", requirePerm("distribution:payment_manage"), distributionHandler.UnbindPaymentChannel) diff --git a/backend/migrations/000074_distribution_enhancement.sql b/backend/migrations/000074_distribution_enhancement.sql new file mode 100644 index 0000000..16c39c3 --- /dev/null +++ b/backend/migrations/000074_distribution_enhancement.sql @@ -0,0 +1,113 @@ +-- +goose Up + +-- 1. distribution_sites 表增加品牌形象与前台装修字段 +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'distribution_sites' AND COLUMN_NAME = 'logo_url') = 0, + 'ALTER TABLE distribution_sites ADD COLUMN logo_url VARCHAR(512) NOT NULL DEFAULT \'\' AFTER display_account_enabled', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'distribution_sites' AND COLUMN_NAME = 'favicon_url') = 0, + 'ALTER TABLE distribution_sites ADD COLUMN favicon_url VARCHAR(512) NOT NULL DEFAULT \'\' AFTER logo_url', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'distribution_sites' AND COLUMN_NAME = 'banners_json') = 0, + 'ALTER TABLE distribution_sites ADD COLUMN banners_json TEXT NULL AFTER favicon_url', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'distribution_sites' AND COLUMN_NAME = 'announcements_json') = 0, + 'ALTER TABLE distribution_sites ADD COLUMN announcements_json TEXT NULL AFTER banners_json', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'distribution_sites' AND COLUMN_NAME = 'footer_info_json') = 0, + 'ALTER TABLE distribution_sites ADD COLUMN footer_info_json TEXT NULL AFTER announcements_json', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 2. distribution_site_accounts 增加自主加价字段 +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'distribution_site_accounts' AND COLUMN_NAME = 'markup_amount_cent') = 0, + 'ALTER TABLE distribution_site_accounts ADD COLUMN markup_amount_cent BIGINT NOT NULL DEFAULT 0 AFTER display_status', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- +goose Down + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'distribution_site_accounts' AND COLUMN_NAME = 'markup_amount_cent') > 0, + 'ALTER TABLE distribution_site_accounts DROP COLUMN markup_amount_cent', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'distribution_sites' AND COLUMN_NAME = 'footer_info_json') > 0, + 'ALTER TABLE distribution_sites DROP COLUMN footer_info_json', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'distribution_sites' AND COLUMN_NAME = 'announcements_json') > 0, + 'ALTER TABLE distribution_sites DROP COLUMN announcements_json', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'distribution_sites' AND COLUMN_NAME = 'banners_json') > 0, + 'ALTER TABLE distribution_sites DROP COLUMN banners_json', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'distribution_sites' AND COLUMN_NAME = 'favicon_url') > 0, + 'ALTER TABLE distribution_sites DROP COLUMN favicon_url', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'distribution_sites' AND COLUMN_NAME = 'logo_url') > 0, + 'ALTER TABLE distribution_sites DROP COLUMN logo_url', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/distribution-admin/src/AppShell.tsx b/distribution-admin/src/AppShell.tsx index f6cb51b..09a7f85 100644 --- a/distribution-admin/src/AppShell.tsx +++ b/distribution-admin/src/AppShell.tsx @@ -5,12 +5,15 @@ import { AccountBookOutlined, ApiOutlined, DashboardOutlined, + DollarCircleOutlined, EyeOutlined, FileSearchOutlined, LogoutOutlined, SettingOutlined, ShopOutlined, + SkinOutlined, UserOutlined, + WalletOutlined, } from '@ant-design/icons' import { getMe, logout, type CurrentMember, getStoredToken } from './api' import Dashboard from './pages/Dashboard' @@ -20,6 +23,8 @@ import Payments from './pages/Payments' import Accounts from './pages/Accounts' import Settlements from './pages/Settlements' import Orders from './pages/Orders' +import Wallet from './pages/Wallet' +import SiteSettings from './pages/SiteSettings' import Login from './pages/Login' import Storefront from './pages/Storefront' import './style.css' @@ -29,11 +34,13 @@ type MenuItem = Required['items'][number] & { superadminOnly?: boolea const ALL_MENU_ITEMS: MenuItem[] = [ { key: '/', icon: , label: 仪表盘 }, { key: '/sites', icon: , label: 分销网站, superadminOnly: true }, - { key: '/sold', icon: , label: 售出账号 }, { key: '/receive', icon: , label: 分销收号 }, - { key: '/settlements', icon: , label: 结算管理 }, + { key: '/sold', icon: , label: 售出账号 }, + { key: '/accounts', icon: , label: 商品展示与加价 }, + { key: '/wallet', icon: , label: 资金与提现 }, + { key: '/settlements', icon: , label: 结算管理 }, + { key: '/settings', icon: , label: 品牌装修 }, { key: '/payments', icon: , label: 支付通道 }, - { key: '/accounts', icon: , label: 账号展示 }, { key: '/rules', icon: , label: 比例与税费 }, ] @@ -131,6 +138,8 @@ function MainLayout({ member, onLogout }: { member: CurrentMember; onLogout: () } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/distribution-admin/src/api.ts b/distribution-admin/src/api.ts index 1716089..b935405 100644 --- a/distribution-admin/src/api.ts +++ b/distribution-admin/src/api.ts @@ -115,6 +115,11 @@ export interface Site { display_account_enabled: boolean tax_enabled: boolean tax_rate_bps: number + logo_url?: string + favicon_url?: string + banners_json?: string + announcements_json?: string + footer_info_json?: string created_at: string } @@ -170,6 +175,7 @@ export interface AccountDisplay { account_id: number listing_id: number display_status: string + markup_amount_cent?: number listing_no?: string title?: string game_name?: string @@ -402,3 +408,174 @@ export async function listSettlementItems(batchID: number): Promise>(`/admin/distribution/settlement-batches/${batchID}/items`) return extract(resp).items ?? [] } + +export interface ReceivedListing { + listing_id: number + listing_no: string + site_id: number + account_id: number + owner_id: number + owner_phone?: string + owner_nickname?: string + title: string + game_name: string + server_region: string + login_platform: string + rank_level: string + haf_coin_amount: number + price_cent: number + deposit_amount_cent: number + status: string + review_status: string + review_reason: string + published_at?: string + created_at: string +} + +export interface ReceivedListingPage { + items: ReceivedListing[] + total: number + page: number + page_size: number +} + +export async function fetchReceivedListings(params: { + siteID?: number + page?: number + pageSize?: number + status?: string + reviewStatus?: string + gameName?: string + keyword?: string +}): Promise { + const resp = await api.get>('/admin/distribution/received-listings', { + params: { + site_id: params.siteID, + page: params.page || 1, + page_size: params.pageSize || 20, + status: params.status || undefined, + review_status: params.reviewStatus || undefined, + game_name: params.gameName || undefined, + keyword: params.keyword || undefined, + }, + }) + return extract(resp) +} + +export async function updateReceivedListingStatus(id: number, status: string): Promise { + await api.put(`/admin/distribution/received-listings/${id}/status`, { status }) +} + +export interface SiteWallet { + site_id: number + available_balance_cent: number + frozen_balance_cent: number + total_settled_cent: number + total_withdrawn_cent: number +} + +export async function fetchSiteWallet(siteID: number): Promise { + const resp = await api.get>(`/admin/distribution/sites/${siteID}/wallet`) + return extract(resp) +} + +export interface SiteWithdrawal { + id: number + withdraw_no: string + site_id: number + amount_cent: number + fee_cent: number + actual_amount_cent: number + account_type: string + account_name: string + account_no: string + bank_name?: string + bank_branch?: string + status: string + review_remark?: string + payment_proof_url?: string + payment_remark?: string + paid_at?: string + created_at: string +} + +export interface SiteWithdrawalPage { + items: SiteWithdrawal[] + total: number + page: number + page_size: number +} + +export async function fetchSiteWithdrawals(siteID: number, page = 1, pageSize = 20): Promise { + const resp = await api.get>(`/admin/distribution/sites/${siteID}/withdrawals`, { + params: { page, page_size: pageSize }, + }) + return extract(resp) +} + +export interface ApplySiteWithdrawalPayload { + site_id: number + amount_cent: number + account_type: 'alipay' | 'bank' + account_name: string + account_no: string + bank_name?: string + bank_branch?: string +} + +export async function applySiteWithdrawal(payload: ApplySiteWithdrawalPayload): Promise { + const resp = await api.post>(`/admin/distribution/sites/${payload.site_id}/withdrawals`, payload) + return extract(resp) +} + +export interface SiteBrandingPayload { + logo_url?: string + favicon_url?: string + banners_json?: string + announcements_json?: string + footer_info_json?: string +} + +export async function updateSiteBranding(siteID: number, payload: SiteBrandingPayload): Promise { + const resp = await api.put>(`/admin/distribution/sites/${siteID}/branding`, payload) + return extract(resp) +} + +export async function updateAccountMarkup(id: number, markupCent: number): Promise { + const resp = await api.put>(`/admin/distribution/accounts/${id}/markup`, { + markup_amount_cent: markupCent, + }) + return extract(resp) +} + +export async function batchUpdateAccountMarkup(siteID: number, listingIDs: number[], markupCent: number): Promise { + await api.post(`/admin/distribution/accounts/batch-markup`, { + listing_ids: listingIDs, + markup_amount_cent: markupCent, + }, { + params: { site_id: siteID }, + }) +} + +export interface DistributionAnalytics { + today_received_count: number + total_received_count: number + active_listing_count: number + today_order_count: number + today_gmv_cent: number + today_profit_cent: number + month_profit_cent: number + available_balance_cent: number + trend_days: { + date: string + received_count: number + order_count: number + gmv_cent: number + profit_cent: number + }[] +} + +export async function fetchDashboardAnalytics(siteID: number): Promise { + const resp = await api.get>(`/admin/distribution/sites/${siteID}/analytics`) + return extract(resp) +} diff --git a/distribution-admin/src/pages/Accounts.tsx b/distribution-admin/src/pages/Accounts.tsx index 48700f7..27f554e 100644 --- a/distribution-admin/src/pages/Accounts.tsx +++ b/distribution-admin/src/pages/Accounts.tsx @@ -1,11 +1,13 @@ import { useEffect, useState, useMemo } from 'react' -import { Card, Table, Typography, Space, Select, Tag, message, Button, Input, Modal } from 'antd' -import { SyncOutlined, SearchOutlined, CheckCircleOutlined, StopOutlined } from '@ant-design/icons' +import { Card, Table, Typography, Space, Select, Tag, message, Button, Input, Modal, InputNumber } from 'antd' +import { SyncOutlined, SearchOutlined, CheckCircleOutlined, StopOutlined, EditOutlined, DollarCircleOutlined } from '@ant-design/icons' import { listAccountDisplays, listSites, updateAccountDisplay, syncSiteAccounts, + updateAccountMarkup, + batchUpdateAccountMarkup, formatMoney, type AccountDisplay, type Site, @@ -28,6 +30,12 @@ export default function Accounts() { const [syncing, setSyncing] = useState(false) const [selectedRowKeys, setSelectedRowKeys] = useState([]) + // Markup modal state + const [markupModalVisible, setMarkupModalVisible] = useState(false) + const [targetAccount, setTargetAccount] = useState(null) + const [markupYuan, setMarkupYuan] = useState(0) + const [markupSaving, setMarkupSaving] = useState(false) + async function load(site?: number, statusFilter?: string) { if (!site) return setLoading(true) @@ -111,6 +119,51 @@ export default function Accounts() { }) } + function handleOpenMarkup(item?: AccountDisplay) { + if (item) { + setTargetAccount(item) + setMarkupYuan((item.markup_amount_cent ?? 0) / 100) + } else { + if (selectedRowKeys.length === 0) { + message.warning('请先勾选需要批量加价的账号商品') + return + } + setTargetAccount(null) + setMarkupYuan(0) + } + setMarkupModalVisible(true) + } + + async function handleSaveMarkup() { + if (!siteID) return + const markupCent = Math.round(markupYuan * 100) + if (markupCent < 0) { + message.error('加价金额不能小于0') + return + } + setMarkupSaving(true) + try { + if (targetAccount) { + await updateAccountMarkup(targetAccount.id, markupCent) + message.success('商品加价设置成功!') + } else { + const listingIDs = items + .filter((it) => selectedRowKeys.includes(it.id)) + .map((it) => it.listing_id) + await batchUpdateAccountMarkup(siteID, listingIDs, markupCent) + message.success(`已成功为选中的 ${listingIDs.length} 个商品批量设置加价!`) + setSelectedRowKeys([]) + } + setMarkupModalVisible(false) + void load(siteID, status) + } catch (error: unknown) { + const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message + message.error(respMessage || '加价设置保存失败') + } finally { + setMarkupSaving(false) + } + } + const filteredItems = useMemo(() => { const kw = keyword.trim().toLowerCase() if (!kw) return items @@ -191,6 +244,13 @@ export default function Accounts() { {selectedRowKeys.length > 0 && ( 已勾选 {selectedRowKeys.length} 项 + + + + + ), }, ]} /> + + setMarkupModalVisible(false)} + onOk={handleSaveMarkup} + confirmLoading={markupSaving} + okText="保存加价" + destroyOnClose + > + + + 自主加价金额将直接叠加在分销前台展示的每小时租金上,该部分溢价产生的收益将在成单结算时归当前分销商所有。 + +
+ 每小时加价金额: + setMarkupYuan(val ?? 0)} + addonAfter="元/小时" + placeholder="0.00" + /> +
+ {markupYuan > 0 && targetAccount && ( +
+ 原租金 {formatMoney(targetAccount.price_cent)}/时 ➔ 分销前台标价为{' '} + + ¥{(((targetAccount.price_cent ?? 0) + Math.round(markupYuan * 100)) / 100).toFixed(2)} + + /时 +
+ )} +
+
) } diff --git a/distribution-admin/src/pages/Dashboard.tsx b/distribution-admin/src/pages/Dashboard.tsx index a6fe11c..a0fd93f 100644 --- a/distribution-admin/src/pages/Dashboard.tsx +++ b/distribution-admin/src/pages/Dashboard.tsx @@ -1,43 +1,252 @@ -import { useEffect, useState } from 'react' -import { Card, Table, Typography, Statistic, Space, message } from 'antd' -import { listOrders, listSites, formatMoney, type Site } from '../api' +import { useEffect, useState, useCallback } from 'react' +import { + Card, + Table, + Typography, + Statistic, + Space, + Row, + Col, + Select, + Tag, + Button, + message, +} from 'antd' +import { + ShoppingCartOutlined, + DollarCircleOutlined, + WalletOutlined, + AppstoreOutlined, + LineChartOutlined, + ArrowRightOutlined, + SkinOutlined, +} from '@ant-design/icons' +import { Link } from 'react-router-dom' +import { + listOrders, + listSites, + fetchDashboardAnalytics, + formatMoney, + type Site, + type DistributionAnalytics, + type DistributionOrder, +} from '../api' export default function Dashboard() { const [sites, setSites] = useState([]) - const [orders, setOrders] = useState>['items']>([]) - const [total, setTotal] = useState(0) + const [siteID, setSiteID] = useState() + const [analytics, setAnalytics] = useState(null) + const [recentOrders, setRecentOrders] = useState([]) + const [loading, setLoading] = useState(false) - useEffect(() => { - Promise.all([listSites(), listOrders({ pageSize: 100 })]) - .then(([siteItems, page]) => { - setSites(siteItems) - setOrders(page.items) - setTotal(page.total) - }) - .catch(() => message.error('数据加载失败')) + const loadData = useCallback(async (sid: number) => { + setLoading(true) + try { + const [anaRes, ordRes] = await Promise.all([ + fetchDashboardAnalytics(sid), + listOrders({ siteID: sid, pageSize: 8 }), + ]) + setAnalytics(anaRes) + setRecentOrders(ordRes.items) + } catch { + message.error('加载大盘数据失败') + } finally { + setLoading(false) + } }, []) - const netPaid = orders.reduce((sum, item) => sum + item.net_paid_amount_cent, 0) + useEffect(() => { + listSites() + .then((rows) => { + setSites(rows) + if (rows.length > 0) { + setSiteID(rows[0].id) + void loadData(rows[0].id) + } + }) + .catch(() => message.error('站点加载失败')) + }, [loadData]) + + const selectedSite = sites.find((s) => s.id === siteID) return ( - - 分销运营概览 -
- - - + +
+
+ + 运营数据大盘 + + + 当前站点:{selectedSite?.site_name || '默认分销站'} ({selectedSite?.site_code}) · 实时聚合收号、货架、订单与结算收益 + +
+ + ({ label: site.site_name, value: site.id }))} - onChange={(value) => setSiteID(value ?? undefined)} + options={sites.map((site) => ({ label: `${site.site_name} (${site.site_code})`, value: site.id }))} + onChange={(value) => setSiteID(value)} /> - ({ label: v.label, value: k }))} + onChange={(val) => { + setRecvReviewStatus(val ?? '') + }} + /> + } + style={{ width: 260 }} + allowClear + value={recvKeyword} + onChange={(e) => setRecvKeyword(e.target.value)} + onPressEnter={() => void loadReceivedListings(1)} + /> + + + + `共 ${tot} 个分销收号商品`, + onChange: (p) => void loadReceivedListings(p), + }} + columns={[ + { + title: '商品编号', + dataIndex: 'listing_no', + width: 140, + render: (v: string) => {v}, + }, + { + title: '账号标题与游戏', + dataIndex: 'title', + minWidth: 220, + render: (_: unknown, row: ReceivedListing) => ( +
+ + {row.title || '无标题'} + + + {row.game_name} + {row.server_region && {row.server_region}} + {row.login_platform && {row.login_platform}} + {row.rank_level && {row.rank_level}} + +
+ ), + }, + { + title: '上传卖家', + dataIndex: 'owner_phone', + width: 150, + render: (_: unknown, row: ReceivedListing) => ( +
+
{row.owner_nickname || '匿名卖家'}
+ + {row.owner_phone || '-'} + +
+ ), + }, + { + title: '价格 / 押金', + width: 140, + render: (_: unknown, row: ReceivedListing) => ( +
+ + {formatMoney(row.price_cent)}/时 + +
+ 押金: {formatMoney(row.deposit_amount_cent)} +
+
+ ), + }, + { + title: '主站审核', + dataIndex: 'review_status', + width: 120, + render: (v: string, row: ReceivedListing) => { + const conf = REVIEW_STATUS_LABELS[v] || { label: v, color: 'default' } + return ( +
+ {conf.label} + {row.review_reason && ( + + {row.review_reason} + + )} +
+ ) + }, + }, + { + title: '货架状态', + dataIndex: 'status', + width: 110, + render: (v: string) => { + const conf = LISTING_STATUS_LABELS[v] || { label: v, color: 'default' } + return {conf.label} + }, + }, + { + title: '上传时间', + dataIndex: 'created_at', + width: 160, + render: (v: string) => {v}, + }, + { + title: '操作', + width: 110, + render: (_: unknown, row: ReceivedListing) => ( + + ), + }, + ]} + /> + + ), + }, + { + key: 'orders', + label: `收号分成订单 (${orderTotal})`, + children: ( + + +
void loadOrders(p), + }} + columns={[ + { title: '订单号', dataIndex: 'order_no' }, + { title: '订单状态', dataIndex: 'status', render: (v: string) => {v} }, + { title: '退款状态', dataIndex: 'refund_status' }, + { title: '净支付', dataIndex: 'net_paid_amount_cent', render: (v: number) => formatMoney(v) }, + { title: '手续费', dataIndex: 'payment_fee_cent', render: (v: number) => formatMoney(v) }, + { title: '服务费', dataIndex: 'service_fee_cent', render: (v: number) => formatMoney(v) }, + { title: '分成基数', dataIndex: 'split_base_amount_cent', render: (v: number) => formatMoney(v) }, + { title: '尾差', dataIndex: 'rounding_adjustment_cent', render: (v: number) => formatMoney(v) }, + { title: '结算', dataIndex: 'settlement_status' }, + { title: '创建时间', dataIndex: 'created_at' }, + ]} + /> + + ), + }, + ]} + /> + + ) : ( + + +
void loadOrders(p) }} + columns={[ + { title: '订单号', dataIndex: 'order_no' }, + { title: '订单状态', dataIndex: 'status', render: (v: string) => {v} }, + { title: '退款状态', dataIndex: 'refund_status' }, + { title: '净支付', dataIndex: 'net_paid_amount_cent', render: (v: number) => formatMoney(v) }, + { title: '手续费', dataIndex: 'payment_fee_cent', render: (v: number) => formatMoney(v) }, + { title: '服务费', dataIndex: 'service_fee_cent', render: (v: number) => formatMoney(v) }, + { title: '分成基数', dataIndex: 'split_base_amount_cent', render: (v: number) => formatMoney(v) }, + { title: '尾差', dataIndex: 'rounding_adjustment_cent', render: (v: number) => formatMoney(v) }, + { title: '结算', dataIndex: 'settlement_status' }, + { title: '创建时间', dataIndex: 'created_at' }, + ]} + /> + + )} ) } diff --git a/distribution-admin/src/pages/SiteSettings.tsx b/distribution-admin/src/pages/SiteSettings.tsx new file mode 100644 index 0000000..17bcb87 --- /dev/null +++ b/distribution-admin/src/pages/SiteSettings.tsx @@ -0,0 +1,293 @@ +import { useEffect, useState, useCallback } from 'react' +import { + Card, + Typography, + Space, + Select, + message, + Button, + Form, + Input, + Row, + Col, + Divider, + Image, + Alert, + Tag, +} from 'antd' +import { + SaveOutlined, + PictureOutlined, + NotificationOutlined, + EyeOutlined, +} from '@ant-design/icons' +import { + listSites, + updateSiteBranding, + type Site, + type SiteBrandingPayload, +} from '../api' + +export default function SiteSettings() { + const [sites, setSites] = useState([]) + const [siteID, setSiteID] = useState() + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [currentSite, setCurrentSite] = useState(null) + + const [form] = Form.useForm() + + const loadSite = useCallback(async (id: number) => { + setLoading(true) + try { + const rows = await listSites() + setSites(rows) + const found = rows.find((s) => s.id === id) + if (found) { + setCurrentSite(found) + form.setFieldsValue({ + logo_url: found.logo_url || '', + favicon_url: found.favicon_url || '', + banners_json: found.banners_json || '[]', + announcements_json: found.announcements_json || '[]', + footer_info_json: found.footer_info_json || '{}', + }) + } + } catch { + message.error('加载站点设置失败') + } finally { + setLoading(false) + } + }, [form]) + + useEffect(() => { + listSites() + .then((rows) => { + setSites(rows) + if (rows.length > 0) { + setSiteID(rows[0].id) + void loadSite(rows[0].id) + } + }) + .catch(() => message.error('站点加载失败')) + }, [loadSite]) + + async function handleSave(values: SiteBrandingPayload) { + if (!siteID) return + // Validate JSON formatting + try { + if (values.banners_json) JSON.parse(values.banners_json) + if (values.announcements_json) JSON.parse(values.announcements_json) + if (values.footer_info_json) JSON.parse(values.footer_info_json) + } catch { + message.error('轮播图、公告或页脚配置的 JSON 格式不正确,请检查!') + return + } + + setSaving(true) + try { + const updated = await updateSiteBranding(siteID, values) + setCurrentSite(updated) + message.success('分销站点品牌与装修配置保存成功!') + } catch (error: unknown) { + const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message + message.error(respMessage || '保存装修配置失败') + } finally { + setSaving(false) + } + } + + const logoUrl = Form.useWatch('logo_url', form) + const bannersJsonStr = Form.useWatch('banners_json', form) + const announcementsJsonStr = Form.useWatch('announcements_json', form) + + let banners: { image_url: string; title?: string }[] = [] + try { + if (bannersJsonStr) banners = JSON.parse(bannersJsonStr) + } catch { + // ignore json error in preview + } + + let announcements: { title: string; content?: string }[] = [] + try { + if (announcementsJsonStr) announcements = JSON.parse(announcementsJsonStr) + } catch { + // ignore + } + + return ( + +
+
+ + 品牌与站点装修 + + + 自定义当前分销站前台 Logo、浏览器图标、轮播横幅、站点公告及页脚备案信息。 + +
+ + + + +
+ + + + + + + + + + + 首页轮播图配置 (Banners JSON) + + } + name="banners_json" + extra='格式:[{"image_url": "图片URL", "title": "标题", "link": "点击跳转链接"}]' + > + + + + + + 站点跑马灯公告 (Announcements JSON) + + } + name="announcements_json" + extra='格式:[{"title": "公告标题", "content": "详细内容"}]' + > + + + + + + + + + + + + + + + + +
+ {/* Header Preview */} +
+
+ {logoUrl ? ( + + ) : ( + + {currentSite?.site_name || '分销站点名称'} + + )} +
+ 分销前台顶栏 +
+ + {/* Announcement Preview */} + {announcements.length > 0 && ( +
+ + + {announcements[0].title} + + } + type="warning" + banner + style={{ borderRadius: 4, padding: '4px 12px' }} + /> +
+ )} + + {/* Banner Preview */} +
+
轮播图预览:
+ {banners.length > 0 && banners[0].image_url ? ( +
+ Banner { + (e.target as HTMLImageElement).src = 'https://images.unsplash.com/photo-1542751371-adc38448a05e?w=600&auto=format&fit=crop&q=60' + }} + /> +
+ ) : ( +
+ 暂无轮播图配置 +
+ )} +
+ + {/* Content Placeholder */} +
+ [ 游戏商品列表展示区域 ] +
+ + {/* Footer Preview */} +
+
{currentSite?.site_name || '分销站点'} · 技术支持由 HFB 平台提供
+
+
+
+ + + + ) +} diff --git a/distribution-admin/src/pages/Wallet.tsx b/distribution-admin/src/pages/Wallet.tsx new file mode 100644 index 0000000..5792aad --- /dev/null +++ b/distribution-admin/src/pages/Wallet.tsx @@ -0,0 +1,399 @@ +import { useEffect, useState, useCallback } from 'react' +import { + Card, + Table, + Typography, + Space, + Select, + Tag, + message, + Button, + Modal, + Form, + Input, + InputNumber, + Radio, + Statistic, + Row, + Col, + Image, +} from 'antd' +import { + WalletOutlined, + PlusOutlined, + DollarCircleOutlined, + LockOutlined, + CheckCircleOutlined, +} from '@ant-design/icons' +import { + fetchSiteWallet, + fetchSiteWithdrawals, + applySiteWithdrawal, + listSites, + formatMoney, + type SiteWallet, + type SiteWithdrawal, + type Site, +} from '../api' + +const STATUS_CONFIG: Record = { + pending: { label: '待审核', color: 'orange' }, + processing: { label: '处理中/审核通过', color: 'blue' }, + completed: { label: '已打款', color: 'success' }, + rejected: { label: '已拒绝', color: 'error' }, +} + +export default function Wallet() { + const [sites, setSites] = useState([]) + const [siteID, setSiteID] = useState() + const [wallet, setWallet] = useState(null) + const [withdrawals, setWithdrawals] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const pageSize = 20 + const [loading, setLoading] = useState(false) + const [modalVisible, setModalVisible] = useState(false) + const [submitting, setSubmitting] = useState(false) + + const [form] = Form.useForm() + + const loadData = useCallback(async (targetPage: number) => { + if (!siteID) return + setLoading(true) + try { + const [walletRes, wdRes] = await Promise.all([ + fetchSiteWallet(siteID), + fetchSiteWithdrawals(siteID, targetPage, pageSize), + ]) + setWallet(walletRes) + setWithdrawals(wdRes.items) + setTotal(wdRes.total) + setPage(wdRes.page) + } catch { + message.error('加载钱包与提现数据失败') + } finally { + setLoading(false) + } + }, [siteID]) + + useEffect(() => { + listSites() + .then((rows) => { + setSites(rows) + if (rows.length > 0) setSiteID(rows[0].id) + }) + .catch(() => message.error('站点加载失败')) + }, []) + + useEffect(() => { + void loadData(1) + }, [loadData]) + + async function handleApplyWithdrawal(values: { + amount: number + account_type: 'alipay' | 'bank' + account_name: string + account_no: string + bank_name?: string + bank_branch?: string + }) { + if (!siteID) return + const amountCent = Math.round(values.amount * 100) + if (amountCent <= 0) { + message.error('提现金额必须大于0') + return + } + if (wallet && amountCent > wallet.available_balance_cent) { + message.error('提现金额不能超过当前可提现余额') + return + } + + setSubmitting(true) + try { + await applySiteWithdrawal({ + site_id: siteID, + amount_cent: amountCent, + account_type: values.account_type, + account_name: values.account_name, + account_no: values.account_no, + bank_name: values.bank_name, + bank_branch: values.bank_branch, + }) + message.success('提现申请提交成功,请等待主站财务审核打款') + setModalVisible(false) + form.resetFields() + void loadData(1) + } catch (error: unknown) { + const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message + message.error(respMessage || '提现申请提交失败') + } finally { + setSubmitting(false) + } + } + + const selectedSite = sites.find((s) => s.id === siteID) + + return ( + +
+
+ + 资金管理与商户提现 + + + 查看分销结算收益到账情况,申请提取收益并实时追踪财务打款流水。 + +
+ +
`共 ${tot} 条提现记录`, + onChange: (p) => void loadData(p), + }} + columns={[ + { + title: '提现单号', + dataIndex: 'withdraw_no', + width: 170, + render: (v: string) => {v}, + }, + { + title: '提现金额', + dataIndex: 'amount_cent', + width: 140, + render: (v: number) => ( + + {formatMoney(v)} + + ), + }, + { + title: '收款账户', + width: 220, + render: (_: unknown, row: SiteWithdrawal) => ( +
+ + + {row.account_type === 'alipay' ? '支付宝' : '银行卡'} + + {row.account_name} + +
{row.account_no}
+ {row.bank_name && ( +
+ {row.bank_name} {row.bank_branch} +
+ )} +
+ ), + }, + { + title: '状态', + dataIndex: 'status', + width: 130, + render: (v: string) => { + const conf = STATUS_CONFIG[v] || { label: v, color: 'default' } + return {conf.label} + }, + }, + { + title: '审核 / 付款备注', + minWidth: 160, + render: (_: unknown, row: SiteWithdrawal) => ( +
+ {row.review_remark && ( +
+ 审核: {row.review_remark} +
+ )} + {row.payment_remark && ( +
+ 打款: {row.payment_remark} +
+ )} + {row.payment_proof_url && ( +
+ +
+ )} + {!row.review_remark && !row.payment_remark && !row.payment_proof_url && '-'} +
+ ), + }, + { + title: '申请时间', + dataIndex: 'created_at', + width: 160, + render: (v: string) => {v}, + }, + { + title: '打款时间', + dataIndex: 'paid_at', + width: 160, + render: (v: string | undefined) => ( + {v || '待打款'} + ), + }, + ]} + /> + + + setModalVisible(false)} + footer={null} + destroyOnClose + > +
+ + + + + { + form.setFieldsValue({ + amount: (wallet?.available_balance_cent ?? 0) / 100, + }) + }} + > + 全部提现 + + } + /> + + + + + 支付宝 + 银行卡 + + + + + + + + + + + + prev.account_type !== cur.account_type} + > + {({ getFieldValue }) => + getFieldValue('account_type') === 'bank' ? ( + <> + + + + + + + + ) : null + } + + + + + + + + + +
+ + ) +} diff --git a/frontend/src/features/listings/views/HomeView.vue b/frontend/src/features/listings/views/HomeView.vue index 4ac7b65..f565ae9 100644 --- a/frontend/src/features/listings/views/HomeView.vue +++ b/frontend/src/features/listings/views/HomeView.vue @@ -47,6 +47,9 @@ const siteStore = useDistributionSiteStore() const announcements = ref(defaultHomeAnnouncements) const displayAnnouncements = computed(() => { if (siteStore.isDistribution) { + if (siteStore.announcements && siteStore.announcements.length > 0) { + return siteStore.announcements + } return [ `【${siteStore.siteName}】官方认证独立分销站:账号实时同步主站,官方质检,安全秒交接!`, ...announcements.value, @@ -55,6 +58,19 @@ const displayAnnouncements = computed(() => { return announcements.value }) const banners = ref(defaultHomeBanners) +const displayBanners = computed(() => { + if (siteStore.isDistribution && siteStore.banners && siteStore.banners.length > 0) { + return siteStore.banners.map((b) => ({ + eyebrow: siteStore.siteName, + title: b.title || '精选游戏账号 · 随租随玩', + badge: '分销精选', + pill: '官方保障', + tone: 'gold', + image_url: b.image_url, + })) + } + return banners.value +}) const awRecycle = ref({ ...defaultAWRecycleConfig }) const awRecycleDialogVisible = ref(false) const cooperationFeedback = ref({ ...defaultCooperationFeedbackConfig }) @@ -463,7 +479,7 @@ if (!restored) {
- +
diff --git a/frontend/src/layouts/AppLayout.css b/frontend/src/layouts/AppLayout.css index a1fb9e6..368913f 100644 --- a/frontend/src/layouts/AppLayout.css +++ b/frontend/src/layouts/AppLayout.css @@ -53,6 +53,15 @@ box-shadow: 0 3px 10px rgba(255, 106, 0, 0.28); } +.pc-brand-logo-img { + width: 38px; + height: 38px; + border-radius: 11px; + object-fit: contain; + background: #ffffff; + border: 1px solid #f1f5f9; +} + .pc-brand-info { display: flex; flex-direction: column; diff --git a/frontend/src/layouts/AppLayout.vue b/frontend/src/layouts/AppLayout.vue index aabc26b..a673723 100644 --- a/frontend/src/layouts/AppLayout.vue +++ b/frontend/src/layouts/AppLayout.vue @@ -146,7 +146,13 @@ async function handleSupportClick() {
- {{ + Site Logo + {{ siteStore.isDistribution ? siteStore.siteName.slice(0, 1) : '锤' }}
@@ -379,15 +385,34 @@ async function handleSupportClick() {
diff --git a/frontend/src/stores/distributionSite.ts b/frontend/src/stores/distributionSite.ts index 39fd50f..37f34b3 100644 --- a/frontend/src/stores/distributionSite.ts +++ b/frontend/src/stores/distributionSite.ts @@ -2,6 +2,19 @@ import { defineStore } from 'pinia' import { apiClient } from '@/shared/api/client' import type { ApiResponse } from '@/shared/types/types' +export interface DistributionBanner { + image_url: string + link_url?: string + title?: string +} + +export interface DistributionFooterInfo { + icp?: string + copyright?: string + contact?: string + disclaimer?: string +} + export interface DistributionSiteInfo { site_code: string site_name: string @@ -9,6 +22,11 @@ export interface DistributionSiteInfo { status: string display_account_enabled: boolean default_support_group_id?: number | null + logo_url?: string + favicon_url?: string + banners_json?: string + announcements_json?: string + footer_info_json?: string } /** @@ -49,6 +67,11 @@ export const useDistributionSiteStore = defineStore('distributionSite', { isDistribution: Boolean(code), displayAccountEnabled: true, defaultSupportGroupId: null as number | null, + logoUrl: '', + faviconUrl: '', + banners: [] as DistributionBanner[], + announcements: [] as string[], + footerInfo: null as DistributionFooterInfo | null, initialized: false, } }, @@ -119,12 +142,53 @@ export const useDistributionSiteStore = defineStore('distributionSite', { this.isDistribution = true this.displayAccountEnabled = info.display_account_enabled ?? true this.defaultSupportGroupId = info.default_support_group_id ?? null + this.logoUrl = info.logo_url || '' + this.faviconUrl = info.favicon_url || '' + + if (info.banners_json) { + try { + this.banners = JSON.parse(info.banners_json) + } catch { + this.banners = [] + } + } else { + this.banners = [] + } + + if (info.announcements_json) { + try { + this.announcements = JSON.parse(info.announcements_json) + } catch { + this.announcements = [] + } + } else { + this.announcements = [] + } + + if (info.footer_info_json) { + try { + this.footerInfo = JSON.parse(info.footer_info_json) + } catch { + this.footerInfo = null + } + } else { + this.footerInfo = null + } sessionStorage.setItem('hfb_site_code', this.siteCode) sessionStorage.setItem('hfb_site_name', this.siteName) - // 更新页面标题 + // 更新页面标题和 Favicon document.title = `【${this.siteName}】租号商城 - 精选游戏账号平台` + if (this.faviconUrl && typeof document !== 'undefined') { + let link = document.querySelector("link[rel*='icon']") as HTMLLinkElement | null + if (!link) { + link = document.createElement('link') + link.rel = 'icon' + document.head.appendChild(link) + } + link.href = this.faviconUrl + } } else { this.resetToMainSite() } @@ -143,6 +207,11 @@ export const useDistributionSiteStore = defineStore('distributionSite', { this.isDistribution = false this.displayAccountEnabled = true this.defaultSupportGroupId = null + this.logoUrl = '' + this.faviconUrl = '' + this.banners = [] + this.announcements = [] + this.footerInfo = null sessionStorage.removeItem('hfb_site_code') sessionStorage.removeItem('hfb_site_name') localStorage.removeItem('hfb_site_code')