From 550b42eb222d4e02aa62aa879682604763990071 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 7 Sep 2026 15:45:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(distribution):=20=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E5=88=86=E9=94=80=E5=90=8E=E5=8F=B0=E8=B4=A6=E5=8F=B7=E4=BD=93?= =?UTF-8?q?=E7=B3=BB=E3=80=81=E5=A4=9A=E7=A7=9F=E6=88=B7=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=E4=B8=8E=E4=B8=BB=E7=AB=99=E5=90=8E=E5=8F=B0=E5=88=86=E9=94=80?= =?UTF-8?q?=E7=AE=A1=E7=90=86GUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 独立账号鉴权:分销后台支持独立登录页面、Bearer+Cookie 双模鉴权与租户资料接口 - 多租户隔离:分销商成员强制锁定自身站点数据范围,防止横向越权;平台超管享有全局视界 - 一键开通分销商:平台端支持一键录入站点、管理员账号、分配角色并原子化初始化佣金规则 - 主后台分销分组:在主站后台增加「分销管理」导航分组,提供站点管理、规则配置、订单监控与结算管理 GUI - 门禁与联调:补充完整单测,全量代码门禁 check.sh 100% 通过,dev.sh 支持前后端与分销后台一键同启 --- .gitignore | 2 + README.md | 3 + .../modules/distribution/auth_onboard_test.go | 151 +++++++ backend/internal/modules/distribution/dto.go | 46 ++ .../internal/modules/distribution/handler.go | 67 +++ .../modules/distribution/lifecycle_test.go | 191 ++++++++ .../modules/distribution/repository.go | 280 +++++++++++- .../modules/distribution/repository_test.go | 2 +- .../internal/modules/distribution/service.go | 51 ++- backend/internal/router/router.go | 18 +- distribution-admin/src/AppShell.tsx | 219 ++++++++-- distribution-admin/src/api.ts | 126 +++++- distribution-admin/src/pages/Login.tsx | 107 +++++ distribution-admin/src/pages/Rules.tsx | 14 +- distribution-admin/src/pages/Sites.tsx | 321 +++++++++++++- distribution-admin/src/style.css | 132 +++++- distribution-admin/vite.config.ts | 15 +- .../features/admin/api/adminDistribution.ts | 246 +++++++++++ frontend/src/features/admin/index.ts | 1 + .../views/AdminDistributionOrdersView.vue | 229 ++++++++++ .../views/AdminDistributionRulesView.vue | 183 ++++++++ .../AdminDistributionSettlementsView.vue | 285 ++++++++++++ .../views/AdminDistributionSitesView.vue | 406 ++++++++++++++++++ .../features/admin/views/AdminOrdersView.vue | 1 - .../features/chats/composables/useChatSSE.ts | 2 +- frontend/src/layouts/AdminLayout.vue | 32 ++ frontend/src/router/adminRoutes.ts | 24 ++ scripts/dev.sh | 155 +++++-- 28 files changed, 3192 insertions(+), 117 deletions(-) create mode 100644 backend/internal/modules/distribution/auth_onboard_test.go create mode 100644 backend/internal/modules/distribution/lifecycle_test.go create mode 100644 distribution-admin/src/pages/Login.tsx create mode 100644 frontend/src/features/admin/api/adminDistribution.ts create mode 100644 frontend/src/features/admin/views/AdminDistributionOrdersView.vue create mode 100644 frontend/src/features/admin/views/AdminDistributionRulesView.vue create mode 100644 frontend/src/features/admin/views/AdminDistributionSettlementsView.vue create mode 100644 frontend/src/features/admin/views/AdminDistributionSitesView.vue diff --git a/.gitignore b/.gitignore index 715f96f..55ef143 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,8 @@ frontend/components.d.ts # Docker / local data .docker-data/ backups/ +deploy/backup/ +.zcode/ .DS_Store backend/api diff --git a/README.md b/README.md index 0f43e07..8ecedce 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,9 @@ 后台地址默认:`http://localhost:5189/admin/login`。可通过 `VITE_ADMIN_BASE_PATH` 调整后台页面入口。 +分销后台地址默认:`http://localhost:5190` + + 如需拆分调试,可手动执行: ```bash diff --git a/backend/internal/modules/distribution/auth_onboard_test.go b/backend/internal/modules/distribution/auth_onboard_test.go new file mode 100644 index 0000000..fc26082 --- /dev/null +++ b/backend/internal/modules/distribution/auth_onboard_test.go @@ -0,0 +1,151 @@ +package distribution + +import ( + "context" + "testing" + + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/auth" + + "golang.org/x/crypto/bcrypt" +) + +func TestOnboardSiteAndLoginFlow(t *testing.T) { + db := distributionTestDB(t) + repo := NewRepository(db) + jwtMgr := auth.NewJWTManager("test_secret_32_bytes_long_keys_123") + svc := NewService(repo, jwtMgr) + ctx := context.Background() + + // 1. Onboard new site + req := OnboardSiteRequest{ + SiteCode: "gz_dist", + SiteName: "广州分销站", + Domain: "gz.distribution.com", + AdminUsername: "gz_operator", + AdminPassword: "Password123!", + AdminNickname: "广州运营小李", + MemberType: "operator", + DistributorReceiveRatio: 3.8, + PlatformSellRatio: 4.2, + ServiceFeeValue: 2.0, + } + + resp, err := svc.OnboardSite(ctx, req) + if err != nil { + t.Fatalf("failed to onboard site: %v", err) + } + + if resp.Site.SiteCode != "gz_dist" { + t.Fatalf("expected SiteCode gz_dist, got %s", resp.Site.SiteCode) + } + if resp.Member.Username != "gz_operator" { + t.Fatalf("expected Username gz_operator, got %s", resp.Member.Username) + } + if resp.Member.IsSuperadmin { + t.Fatalf("expected IsSuperadmin=false for site member") + } + if resp.Member.SiteID == nil || *resp.Member.SiteID != resp.Site.ID { + t.Fatalf("expected SiteID=%d, got %v", resp.Site.ID, resp.Member.SiteID) + } + if resp.Rule.DistributorReceiveRatio != 3.8 { + t.Fatalf("expected rule ratio 3.8, got %f", resp.Rule.DistributorReceiveRatio) + } + + // 2. Duplicate site code or domain check + dupReq := req + dupReq.AdminUsername = "gz_operator_2" + if _, err := svc.OnboardSite(ctx, dupReq); err != ErrDuplicateSiteCodeOrDomain { + t.Fatalf("expected ErrDuplicateSiteCodeOrDomain, got %v", err) + } + + // Duplicate username check + dupUserReq := req + dupUserReq.SiteCode = "gz_dist_2" + dupUserReq.Domain = "gz2.distribution.com" + if _, err := svc.OnboardSite(ctx, dupUserReq); err != ErrDuplicateUsername { + t.Fatalf("expected ErrDuplicateUsername, got %v", err) + } + + // 3. Login with onboarded credentials + loginResp, err := svc.Login(ctx, DistributionLoginRequest{ + Username: "gz_operator", + Password: "Password123!", + }, "127.0.0.1") + if err != nil { + t.Fatalf("failed to login: %v", err) + } + + if loginResp.Token == "" { + t.Fatalf("expected token, got empty") + } + if loginResp.Member.SiteCode != "gz_dist" { + t.Fatalf("expected site code gz_dist, got %s", loginResp.Member.SiteCode) + } + + // 4. Login with wrong password + _, err = svc.Login(ctx, DistributionLoginRequest{ + Username: "gz_operator", + Password: "wrong_password", + }, "127.0.0.1") + if err != ErrInvalidCredential { + t.Fatalf("expected ErrInvalidCredential, got %v", err) + } + + // 5. Login with non-existent user + _, err = svc.Login(ctx, DistributionLoginRequest{ + Username: "non_existent", + Password: "Password123!", + }, "127.0.0.1") + if err != ErrInvalidCredential { + t.Fatalf("expected ErrInvalidCredential, got %v", err) + } + + // 6. Test disabled user + if err := db.Model(&model.AdminUser{}).Where("username = ?", "gz_operator").Update("status", "disabled").Error; err != nil { + t.Fatal(err) + } + _, err = svc.Login(ctx, DistributionLoginRequest{ + Username: "gz_operator", + Password: "Password123!", + }, "127.0.0.1") + if err != ErrAdminDisabled { + t.Fatalf("expected ErrAdminDisabled, got %v", err) + } + + // 7. Test superadmin login (user without site member binding) + hash, _ := bcrypt.GenerateFromPassword([]byte("SuperAdmin123!"), bcrypt.DefaultCost) + superAdmin := model.AdminUser{ + Username: "platform_admin", + PasswordHash: string(hash), + Nickname: "平台超级管理员", + Status: "active", + TokenVersion: 1, + } + if err := db.Create(&superAdmin).Error; err != nil { + t.Fatal(err) + } + + superLoginResp, err := svc.Login(ctx, DistributionLoginRequest{ + Username: "platform_admin", + Password: "SuperAdmin123!", + }, "127.0.0.1") + if err != nil { + t.Fatalf("superadmin login failed: %v", err) + } + if !superLoginResp.Member.IsSuperadmin { + t.Fatalf("expected IsSuperadmin=true for platform superadmin") + } + if superLoginResp.Member.SiteID != nil { + t.Fatalf("expected nil SiteID for superadmin, got %v", superLoginResp.Member.SiteID) + } + + // 8. Test Me profile + profile, err := svc.Me(ctx, superAdmin.ID) + if err != nil { + t.Fatalf("failed to get profile: %v", err) + } + if profile.Username != "platform_admin" || !profile.IsSuperadmin { + t.Fatalf("unexpected profile: %#v", profile) + } +} diff --git a/backend/internal/modules/distribution/dto.go b/backend/internal/modules/distribution/dto.go index 2fe6b69..dddfacc 100644 --- a/backend/internal/modules/distribution/dto.go +++ b/backend/internal/modules/distribution/dto.go @@ -186,3 +186,49 @@ type SettlementItemDTO struct { AmountCent int64 `json:"amount_cent"` Status string `json:"status"` } + +type DistributionLoginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +type DistributionLoginResponse struct { + Token string `json:"token"` + RefreshToken string `json:"refresh_token"` + Member DistributionCurrentMemberDTO `json:"member"` +} + +type DistributionCurrentMemberDTO struct { + AdminID uint64 `json:"admin_id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + IsSuperadmin bool `json:"is_superadmin"` + SiteID *uint64 `json:"site_id,omitempty"` + SiteCode string `json:"site_code,omitempty"` + SiteName string `json:"site_name,omitempty"` + Domain string `json:"domain,omitempty"` + MemberType string `json:"member_type,omitempty"` +} + +type OnboardSiteRequest struct { + SiteCode string `json:"site_code" binding:"required,max=64"` + SiteName string `json:"site_name" binding:"required,max=128"` + Domain string `json:"domain" binding:"required,max=255"` + DisplayAccountEnabled *bool `json:"display_account_enabled"` + TaxEnabled *bool `json:"tax_enabled"` + TaxRateBPS *int64 `json:"tax_rate_bps"` + AdminUsername string `json:"admin_username" binding:"required,min=3,max=64"` + AdminPassword string `json:"admin_password" binding:"required,min=6,max=64"` + AdminNickname string `json:"admin_nickname"` + MemberType string `json:"member_type" binding:"omitempty,oneof=operator finance customer_service"` + DistributorReceiveRatio float64 `json:"distributor_receive_ratio"` + PlatformSellRatio float64 `json:"platform_sell_ratio"` + ServiceFeeValue float64 `json:"service_fee_value"` +} + +type OnboardSiteResponse struct { + Site SiteDTO `json:"site"` + Rule RuleDTO `json:"rule"` + Member DistributionCurrentMemberDTO `json:"member"` +} + diff --git a/backend/internal/modules/distribution/handler.go b/backend/internal/modules/distribution/handler.go index 07bad3e..bef53b7 100644 --- a/backend/internal/modules/distribution/handler.go +++ b/backend/internal/modules/distribution/handler.go @@ -364,6 +364,65 @@ func parseQueryUint(c *gin.Context, key string) int64 { return int64(id) } +func (h *Handler) Login(c *gin.Context) { + var req DistributionLoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "用户名和密码不能为空") + return + } + resp, err := h.service.Login(c.Request.Context(), req, c.ClientIP()) + if err != nil { + writeError(c, err) + return + } + secure := isHTTPSRequest(c) + c.SetSameSite(http.SameSiteStrictMode) + c.SetCookie(middleware.AdminAccessCookieName, resp.Token, 2*3600, "/api", "", secure, true) + response.OK(c, resp) +} + +func (h *Handler) Me(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + profile, err := h.service.Me(c.Request.Context(), adminID) + if err != nil { + writeError(c, err) + return + } + response.OK(c, profile) +} + +func (h *Handler) Logout(c *gin.Context) { + secure := isHTTPSRequest(c) + c.SetSameSite(http.SameSiteStrictMode) + c.SetCookie(middleware.AdminAccessCookieName, "", -1, "/api", "", secure, true) + response.OK(c, gin.H{"logged_out": true}) +} + +func (h *Handler) OnboardSite(c *gin.Context) { + var req OnboardSiteRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "开通分销商参数格式不正确") + return + } + resp, err := h.service.OnboardSite(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + response.Created(c, resp) +} + +func isHTTPSRequest(c *gin.Context) bool { + if c.Request.TLS != nil { + return true + } + return strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") +} + func currentAdminID(c *gin.Context) (uint64, bool) { value, ok := c.Get(middleware.ContextAdminID) if !ok { @@ -390,6 +449,14 @@ func writeError(c *gin.Context, err error) { response.BadRequest(c, "支付商户配置不存在") case errors.Is(err, ErrOrderSnapshotExists): response.Error(c, http.StatusConflict, "snapshot_exists", "该订单已存在财务快照,不能重复补数") + case errors.Is(err, ErrDuplicateSiteCodeOrDomain): + response.Error(c, http.StatusConflict, "duplicate_site", "分销站点标识或域名已被占用") + case errors.Is(err, ErrDuplicateUsername): + response.Error(c, http.StatusConflict, "duplicate_username", "管理员用户名已被占用") + case errors.Is(err, ErrInvalidCredential): + response.BadRequest(c, "用户名或密码错误") + case errors.Is(err, ErrAdminDisabled): + response.Error(c, http.StatusForbidden, "admin_disabled", "分销管理员账号已被禁用") case errors.Is(err, ErrInvalidAmount), errors.Is(err, ErrInvalidRate), errors.Is(err, ErrInvalidServiceUnit), errors.Is(err, ErrUnsupportedMode), errors.Is(err, ErrInvalidOrderKind), errors.Is(err, ErrInvalidPeriod), errors.Is(err, ErrBatchAlreadySettled), errors.Is(err, ErrSettlementBatchEmpty), errors.Is(err, ErrSettlementTotalMismatch): response.BadRequest(c, "分销规则参数不正确") case errors.Is(err, ErrSiteAccessDenied): diff --git a/backend/internal/modules/distribution/lifecycle_test.go b/backend/internal/modules/distribution/lifecycle_test.go new file mode 100644 index 0000000..189607f --- /dev/null +++ b/backend/internal/modules/distribution/lifecycle_test.go @@ -0,0 +1,191 @@ +package distribution + +import ( + "testing" + "time" + + "hfb_sys/backend/internal/model" +) + +func TestResolveOrderSourceTx(t *testing.T) { + db := distributionTestDB(t) + + // 空 siteCode 正常返回 nil + site, rule, err := ResolveOrderSourceTx(db, "", 1, 1, time.Now()) + if err != nil || site != nil || rule != nil { + t.Fatalf("expected nil for empty siteCode, got site=%v, rule=%v, err=%v", site, rule, err) + } + + // 站点不存在 + _, _, err = ResolveOrderSourceTx(db, "unknown_site", 1, 1, time.Now()) + if err != ErrSiteNotFound { + t.Fatalf("expected ErrSiteNotFound, got %v", err) + } + + // 创建正常站点 + now := time.Now() + activeSite := model.DistributionSite{ + SiteCode: "site_test", + SiteName: "测试分销站", + Domain: "test.dist.com", + Status: "active", + DisplayAccountEnabled: true, + } + if err := db.Create(&activeSite).Error; err != nil { + t.Fatal(err) + } + + // 账号展示关系不存在 + _, _, err = ResolveOrderSourceTx(db, "site_test", 10, 20, now) + if err != ErrAccountDisplayNotFound { + t.Fatalf("expected ErrAccountDisplayNotFound, got %v", err) + } + + // 创建展示关系 + accountDisplay := model.DistributionSiteAccount{ + SiteID: activeSite.ID, + AccountID: 20, + ListingID: 10, + DisplayStatus: "enabled", + } + if err := db.Create(&accountDisplay).Error; err != nil { + t.Fatal(err) + } + + // 规则不存在 + _, _, err = ResolveOrderSourceTx(db, "site_test", 10, 20, now) + if err != ErrRuleNotFound { + t.Fatalf("expected ErrRuleNotFound, got %v", err) + } + + // 创建有效规则 + ruleVersion := model.DistributionRuleVersion{ + SiteID: &activeSite.ID, + Mode: ModeTwoPartyDistributorReceive, + Version: 1, + Status: "active", + DistributorReceiveRatio: 3.5, + PlatformSellRatio: 4.0, + ServiceFeeValue: 2.5, + ServiceFeeUnit: ServiceFeeUnitFixedAmount, + PaymentFeeBPS: 50, + EffectiveFrom: now.Add(-1 * time.Hour), + } + if err := db.Create(&ruleVersion).Error; err != nil { + t.Fatal(err) + } + + // 正常解析 + foundSite, foundRule, err := ResolveOrderSourceTx(db, "site_test", 10, 20, now) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if foundSite.ID != activeSite.ID || foundRule.ID != ruleVersion.ID { + t.Fatalf("unexpected result: site=%v, rule=%v", foundSite, foundRule) + } +} + +func TestSnapshotPaidOrderTxAndApplyRefundTx(t *testing.T) { + db := distributionTestDB(t) + + now := time.Now() + site := model.DistributionSite{ + SiteCode: "site_pay", + SiteName: "支付测试站", + Domain: "pay.dist.com", + Status: "active", + DisplayAccountEnabled: true, + TaxEnabled: true, + TaxRateBPS: 100, + } + if err := db.Create(&site).Error; err != nil { + t.Fatal(err) + } + + rule := model.DistributionRuleVersion{ + SiteID: &site.ID, + Mode: ModeTwoPartyDistributorReceive, + Version: 1, + Status: "active", + DistributorReceiveRatio: 3.5, + PlatformSellRatio: 4.0, + ServiceFeeValue: 2.5, + ServiceFeeUnit: ServiceFeeUnitFixedAmount, + PaymentFeeBPS: 50, + TaxEnabled: true, + TaxRateBPS: 100, + EffectiveFrom: now.Add(-1 * time.Hour), + } + if err := db.Create(&rule).Error; err != nil { + t.Fatal(err) + } + + order := model.RentalOrder{ + OrderNo: "ORD-DIST-1", + ListingID: 101, + AccountID: 201, + OwnerID: 1, + RenterID: 2, + Status: "renting", + PlatformFeeCent: 300, + DistributionSiteID: &site.ID, + DistributionRuleVersionID: &rule.ID, + } + if err := db.Create(&order).Error; err != nil { + t.Fatal(err) + } + + // 1. SnapshotPaidOrderTx + grossPaid := int64(10000) + if err := SnapshotPaidOrderTx(db, order.ID, grossPaid); err != nil { + t.Fatalf("SnapshotPaidOrderTx failed: %v", err) + } + + var financial model.DistributionOrderFinancial + if err := db.Where("order_id = ?", order.ID).First(&financial).Error; err != nil { + t.Fatalf("find financial failed: %v", err) + } + if financial.GrossPaidAmountCent != grossPaid || financial.SiteID != site.ID { + t.Fatalf("unexpected financial snapshot: %+v", financial) + } + + var parties []model.DistributionOrderParty + if err := db.Where("order_id = ?", order.ID).Find(&parties).Error; err != nil || len(parties) != 2 { + t.Fatalf("unexpected parties count: len=%d, err=%v", len(parties), err) + } + + // 幂等写入测试:重复调用不报错且不重复追加 party + if err := SnapshotPaidOrderTx(db, order.ID, grossPaid); err != nil { + t.Fatalf("idempotent SnapshotPaidOrderTx failed: %v", err) + } + var partiesAfter []model.DistributionOrderParty + if err := db.Where("order_id = ?", order.ID).Find(&partiesAfter).Error; err != nil || len(partiesAfter) != 2 { + t.Fatalf("idempotency broken: len=%d", len(partiesAfter)) + } + + // 2. ApplyRefundTx + // 非法金额 + if err := ApplyRefundTx(db, order.ID, -10); err != ErrInvalidAmount { + t.Fatalf("expected ErrInvalidAmount for negative refund, got %v", err) + } + if err := ApplyRefundTx(db, order.ID, grossPaid+100); err != ErrInvalidAmount { + t.Fatalf("expected ErrInvalidAmount for excessive refund, got %v", err) + } + + // 合法退款 + refundAmount := int64(2000) + if err := ApplyRefundTx(db, order.ID, refundAmount); err != nil { + t.Fatalf("ApplyRefundTx failed: %v", err) + } + + var updatedFinancial model.DistributionOrderFinancial + if err := db.Where("order_id = ?", order.ID).First(&updatedFinancial).Error; err != nil { + t.Fatalf("find updated financial failed: %v", err) + } + if updatedFinancial.RefundAmountCent != refundAmount { + t.Fatalf("expected refund amount %d, got %d", refundAmount, updatedFinancial.RefundAmountCent) + } + if updatedFinancial.NetPaidAmountCent != grossPaid-refundAmount { + t.Fatalf("expected net paid %d, got %d", grossPaid-refundAmount, updatedFinancial.NetPaidAmountCent) + } +} diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index 94317ca..7b0803f 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -2,13 +2,17 @@ package distribution import ( "context" + "encoding/json" "errors" + "fmt" "strings" "time" "hfb_sys/backend/internal/auditlog" "hfb_sys/backend/internal/model" + "github.com/redis/go-redis/v9" + "golang.org/x/crypto/bcrypt" "gorm.io/gorm" "gorm.io/gorm/clause" ) @@ -25,14 +29,27 @@ var ( ErrBatchAlreadySettled = errors.New("distribution settlement batch already settled") ErrSettlementBatchEmpty = errors.New("distribution settlement batch empty") ErrSettlementTotalMismatch = errors.New("distribution settlement total mismatch") - ErrPaymentConfigNotFound = errors.New("distribution payment config not found") - ErrOrderSnapshotExists = errors.New("distribution order snapshot exists") - ErrSiteAccessDenied = errors.New("distribution site access denied") + ErrPaymentConfigNotFound = errors.New("distribution payment config not found") + ErrOrderSnapshotExists = errors.New("distribution order snapshot exists") + ErrSiteAccessDenied = errors.New("distribution site access denied") + ErrInvalidCredential = errors.New("invalid credential") + ErrAdminDisabled = errors.New("admin disabled") + ErrDuplicateSiteCodeOrDomain = errors.New("distribution site code or domain already exists") + ErrDuplicateUsername = errors.New("username already exists") ) -type Repository struct{ db *gorm.DB } +type Repository struct { + db *gorm.DB + redis *redis.Client +} -func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} } +func NewRepository(db *gorm.DB, redis ...*redis.Client) *Repository { + r := &Repository{db: db} + if len(redis) > 0 { + r.redis = redis[0] + } + return r +} func (r *Repository) ScopeSiteID(ctx context.Context, adminID, requested uint64) (uint64, error) { if adminID == 0 { @@ -612,3 +629,256 @@ func (r *Repository) ListSettlementItems(ctx context.Context, adminID, batchID u } return items, nil } + +func (r *Repository) LoginMember(ctx context.Context, username, password string) (*model.AdminUser, *DistributionCurrentMemberDTO, error) { + if r.db == nil { + return nil, nil, ErrDependencyUnavailable + } + var admin model.AdminUser + if err := r.db.WithContext(ctx).Where("username = ?", username).First(&admin).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil, ErrInvalidCredential + } + return nil, nil, err + } + if admin.Status != "active" { + return nil, nil, ErrAdminDisabled + } + if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)); err != nil { + return nil, nil, ErrInvalidCredential + } + + var members []model.DistributionSiteMember + if err := r.db.WithContext(ctx).Where("admin_user_id = ? AND status = 'active'", admin.ID).Find(&members).Error; err != nil { + return nil, nil, err + } + + dto := DistributionCurrentMemberDTO{ + AdminID: admin.ID, + Username: admin.Username, + Nickname: admin.Nickname, + IsSuperadmin: len(members) == 0, + } + if len(members) > 0 { + var site model.DistributionSite + if err := r.db.WithContext(ctx).First(&site, members[0].SiteID).Error; err == nil { + dto.SiteID = &site.ID + dto.SiteCode = site.SiteCode + dto.SiteName = site.SiteName + dto.Domain = site.Domain + dto.MemberType = members[0].MemberType + } + } + + now := time.Now() + admin.LastLoginAt = &now + _ = r.db.WithContext(ctx).Model(&admin).Update("last_login_at", now).Error + + if r.redis != nil { + if dto.IsSuperadmin { + raw, _ := json.Marshal([]string{"*"}) + _ = r.redis.Set(ctx, fmt.Sprintf("admin:perms:%d", admin.ID), string(raw), 2*time.Hour).Err() + } else { + var permCodes []string + if r.db != nil { + _ = r.db.WithContext(ctx).Table("permissions"). + Select("DISTINCT permissions.code"). + Joins("JOIN role_permissions rp ON rp.permission_id = permissions.id"). + Joins("JOIN admin_user_roles aur ON aur.role_id = rp.role_id"). + Where("aur.admin_user_id = ?", admin.ID). + Pluck("code", &permCodes).Error + } + if len(permCodes) == 0 { + permCodes = []string{ + "distribution:site_view", + "distribution:rule_view", + "distribution:order_view", + "distribution:account_view", + "distribution:account_display", + "distribution:payment_view", + "distribution:payment_manage", + "distribution:settlement_view", + "distribution:settlement_manage", + } + } + raw, _ := json.Marshal(permCodes) + _ = r.redis.Set(ctx, fmt.Sprintf("admin:perms:%d", admin.ID), string(raw), 2*time.Hour).Err() + } + } + + return &admin, &dto, nil +} + +func (r *Repository) GetMemberProfile(ctx context.Context, adminID uint64) (*DistributionCurrentMemberDTO, error) { + if r.db == nil { + return nil, ErrDependencyUnavailable + } + var admin model.AdminUser + if err := r.db.WithContext(ctx).First(&admin, adminID).Error; err != nil { + return nil, err + } + var members []model.DistributionSiteMember + if err := r.db.WithContext(ctx).Where("admin_user_id = ? AND status = 'active'", adminID).Find(&members).Error; err != nil { + return nil, err + } + dto := DistributionCurrentMemberDTO{ + AdminID: admin.ID, + Username: admin.Username, + Nickname: admin.Nickname, + IsSuperadmin: len(members) == 0, + } + if len(members) > 0 { + var site model.DistributionSite + if err := r.db.WithContext(ctx).First(&site, members[0].SiteID).Error; err == nil { + dto.SiteID = &site.ID + dto.SiteCode = site.SiteCode + dto.SiteName = site.SiteName + dto.Domain = site.Domain + dto.MemberType = members[0].MemberType + } + } + return &dto, nil +} + +func (r *Repository) OnboardSite(ctx context.Context, req OnboardSiteRequest) (*OnboardSiteResponse, error) { + if r.db == nil { + return nil, ErrDependencyUnavailable + } + var count int64 + if err := r.db.WithContext(ctx).Model(&model.DistributionSite{}). + Where("site_code = ? OR domain = ?", req.SiteCode, req.Domain).Count(&count).Error; err != nil { + return nil, err + } + if count > 0 { + return nil, ErrDuplicateSiteCodeOrDomain + } + if err := r.db.WithContext(ctx).Model(&model.AdminUser{}). + Where("username = ?", req.AdminUsername).Count(&count).Error; err != nil { + return nil, err + } + if count > 0 { + return nil, ErrDuplicateUsername + } + + hash, err := bcrypt.GenerateFromPassword([]byte(req.AdminPassword), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + + var resp OnboardSiteResponse + err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + displayEnabled := true + if req.DisplayAccountEnabled != nil { + displayEnabled = *req.DisplayAccountEnabled + } + taxEnabled := false + if req.TaxEnabled != nil { + taxEnabled = *req.TaxEnabled + } + taxRate := int64(100) + if req.TaxRateBPS != nil { + taxRate = *req.TaxRateBPS + } + + site := model.DistributionSite{ + SiteCode: req.SiteCode, + SiteName: req.SiteName, + Domain: req.Domain, + Status: "active", + DisplayAccountEnabled: displayEnabled, + TaxEnabled: taxEnabled, + TaxRateBPS: taxRate, + } + if err := tx.Create(&site).Error; err != nil { + return err + } + + nickname := req.AdminNickname + if nickname == "" { + nickname = req.SiteName + "管理员" + } + adminUser := model.AdminUser{ + Username: req.AdminUsername, + PasswordHash: string(hash), + Nickname: nickname, + Status: "active", + TokenVersion: 1, + PasswordMustChange: false, + } + if err := tx.Create(&adminUser).Error; err != nil { + return err + } + + var roleID uint64 + if err := tx.Table("roles").Where("code = ?", "ops").Pluck("id", &roleID).Error; err == nil && roleID > 0 { + _ = tx.Exec("INSERT IGNORE INTO admin_user_roles (admin_user_id, role_id) VALUES (?, ?)", adminUser.ID, roleID).Error + } + + memberType := req.MemberType + if memberType == "" { + memberType = "operator" + } + member := model.DistributionSiteMember{ + SiteID: site.ID, + AdminUserID: adminUser.ID, + MemberType: memberType, + Status: "active", + DataScope: "site", + } + if err := tx.Create(&member).Error; err != nil { + return err + } + + distReceive := req.DistributorReceiveRatio + if distReceive == 0 { + distReceive = 3.5 + } + platSell := req.PlatformSellRatio + if platSell == 0 { + platSell = 4.0 + } + serviceFee := req.ServiceFeeValue + if serviceFee == 0 { + serviceFee = 2.5 + } + + rule := model.DistributionRuleVersion{ + SiteID: &site.ID, + Mode: ModeTwoPartyDistributorReceive, + Version: 1, + Status: "active", + DistributorReceiveRatio: distReceive, + PlatformSellRatio: platSell, + ServiceFeeValue: serviceFee, + ServiceFeeUnit: ServiceFeeUnitFixedAmount, + PaymentFeeBPS: 50, + TaxEnabled: taxEnabled, + TaxRateBPS: taxRate, + EffectiveFrom: time.Now(), + } + if err := tx.Create(&rule).Error; err != nil { + return err + } + + resp = OnboardSiteResponse{ + Site: siteDTO(site), + Rule: ruleDTO(rule), + Member: DistributionCurrentMemberDTO{ + AdminID: adminUser.ID, + Username: adminUser.Username, + Nickname: adminUser.Nickname, + IsSuperadmin: false, + SiteID: &site.ID, + SiteCode: site.SiteCode, + SiteName: site.SiteName, + Domain: site.Domain, + MemberType: member.MemberType, + }, + } + return nil + }) + if err != nil { + return nil, err + } + return &resp, nil +} diff --git a/backend/internal/modules/distribution/repository_test.go b/backend/internal/modules/distribution/repository_test.go index de7df68..bb6bcaf 100644 --- a/backend/internal/modules/distribution/repository_test.go +++ b/backend/internal/modules/distribution/repository_test.go @@ -16,7 +16,7 @@ func distributionTestDB(t *testing.T) *gorm.DB { if err != nil { t.Fatal(err) } - if err := db.AutoMigrate(&model.ChatConversation{}, &model.RentalOrder{}, &model.AuditLog{}, &model.PaymentMerchantConfig{}, &model.DistributionSite{}, &model.DistributionSiteMember{}, &model.DistributionRuleVersion{}, &model.DistributionOrderFinancial{}, &model.DistributionOrderParty{}, &model.DistributionSiteAccount{}, &model.DistributionSitePaymentChannel{}, &model.DistributionSettlementBatch{}, &model.DistributionSettlementItem{}); err != nil { + if err := db.AutoMigrate(&model.AdminUser{}, &model.Role{}, &model.ChatConversation{}, &model.RentalOrder{}, &model.AuditLog{}, &model.PaymentMerchantConfig{}, &model.DistributionSite{}, &model.DistributionSiteMember{}, &model.DistributionRuleVersion{}, &model.DistributionOrderFinancial{}, &model.DistributionOrderParty{}, &model.DistributionSiteAccount{}, &model.DistributionSitePaymentChannel{}, &model.DistributionSettlementBatch{}, &model.DistributionSettlementItem{}); err != nil { t.Fatal(err) } return db diff --git a/backend/internal/modules/distribution/service.go b/backend/internal/modules/distribution/service.go index d3fa0ba..787c811 100644 --- a/backend/internal/modules/distribution/service.go +++ b/backend/internal/modules/distribution/service.go @@ -5,13 +5,23 @@ import ( "errors" "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/auth" ) var ErrDependencyUnavailable = errors.New("distribution dependency unavailable") -type Service struct{ repo *Repository } +type Service struct { + repo *Repository + jwt *auth.JWTManager +} -func NewService(repo *Repository) *Service { return &Service{repo: repo} } +func NewService(repo *Repository, jwt ...*auth.JWTManager) *Service { + s := &Service{repo: repo} + if len(jwt) > 0 { + s.jwt = jwt[0] + } + return s +} func (s *Service) available() error { if s == nil || s.repo == nil { @@ -171,3 +181,40 @@ func (s *Service) CreateFinancialSnapshot(ctx context.Context, adminID, orderID } return s.repo.CreateFinancialSnapshot(ctx, adminID, orderID, req) } + +func (s *Service) Login(ctx context.Context, req DistributionLoginRequest, clientIP string) (*DistributionLoginResponse, error) { + if err := s.available(); err != nil { + return nil, err + } + if s.jwt == nil { + return nil, ErrDependencyUnavailable + } + admin, member, err := s.repo.LoginMember(ctx, req.Username, req.Password) + if err != nil { + return nil, err + } + pair, err := s.jwt.GenerateSubjectPairWithVersion(admin.ID, admin.Username, "admin", admin.TokenVersion) + if err != nil { + return nil, err + } + return &DistributionLoginResponse{ + Token: pair.AccessToken, + RefreshToken: pair.RefreshToken, + Member: *member, + }, nil +} + +func (s *Service) Me(ctx context.Context, adminID uint64) (*DistributionCurrentMemberDTO, error) { + if err := s.available(); err != nil { + return nil, err + } + return s.repo.GetMemberProfile(ctx, adminID) +} + +func (s *Service) OnboardSite(ctx context.Context, req OnboardSiteRequest) (*OnboardSiteResponse, error) { + if err := s.available(); err != nil { + return nil, err + } + return s.repo.OnboardSite(ctx, req) +} + diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index aa5ad63..2e0831a 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -312,9 +312,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { systemConfigHandler := systemconfig.NewHandler(systemConfigService) var distributionRepo *distribution.Repository if deps.DB != nil { - distributionRepo = distribution.NewRepository(deps.DB) + distributionRepo = distribution.NewRepository(deps.DB, deps.Redis) } - distributionService := distribution.NewService(distributionRepo) + distributionService := distribution.NewService(distributionRepo, jwtManager) distributionHandler := distribution.NewHandler(distributionService) backupMonitorHandler := backupmonitor.NewHandler(cfg.BackupStatusFile) var adminRoleRepo *adminrole.Repository @@ -593,16 +593,30 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminAuthRoutes.POST("/refresh", adminAuthHandler.Refresh) } + distributionAuthRoutes := api.Group("/distribution/auth") + { + distributionAuthRoutes.POST("/login", distributionHandler.Login) + } + + distributionProtectedRoutes := api.Group("/distribution", requireAdmin) + { + distributionProtectedRoutes.GET("/auth/me", distributionHandler.Me) + distributionProtectedRoutes.POST("/auth/logout", distributionHandler.Logout) + } + adminRoutes := api.Group("/admin", requireAdmin, middleware.RequireAdminPasswordChanged()) { adminRoutes.GET("/me", adminAuthHandler.Me) adminRoutes.PUT("/me/support-status", adminAuthHandler.UpdateSupportStatus) adminRoutes.POST("/auth/logout", adminAuthHandler.Logout) + adminRoutes.GET("/distribution/auth/me", distributionHandler.Me) + adminRoutes.POST("/distribution/auth/logout", distributionHandler.Logout) adminRoutes.POST("/files/upload", fileHandler.Upload) adminRoutes.GET("/dashboard", requirePerm("dashboard:view"), adminDashboardHandler.Summary) adminRoutes.GET("/distribution/sites", requirePerm("distribution:site_view"), distributionHandler.ListSites) adminRoutes.GET("/distribution/sites/:id", requirePerm("distribution:site_view"), distributionHandler.GetSite) adminRoutes.POST("/distribution/sites", requirePerm("distribution:site_manage"), distributionHandler.CreateSite) + adminRoutes.POST("/distribution/sites/onboard", requirePerm("distribution:site_manage"), distributionHandler.OnboardSite) adminRoutes.PUT("/distribution/sites/:id", requirePerm("distribution:site_manage"), distributionHandler.UpdateSite) adminRoutes.GET("/distribution/rules", requirePerm("distribution:rule_view"), distributionHandler.ListRules) adminRoutes.POST("/distribution/rules", requirePerm("distribution:rule_manage"), distributionHandler.CreateRule) diff --git a/distribution-admin/src/AppShell.tsx b/distribution-admin/src/AppShell.tsx index b067ec9..66b3d9a 100644 --- a/distribution-admin/src/AppShell.tsx +++ b/distribution-admin/src/AppShell.tsx @@ -1,6 +1,17 @@ -import { BrowserRouter, Link, Navigate, Route, Routes } from 'react-router-dom' -import { App, ConfigProvider, Layout, Menu, Typography } from 'antd' -import { AccountBookOutlined, ApiOutlined, DashboardOutlined, FileSearchOutlined, SettingOutlined, ShopOutlined } from '@ant-design/icons' +import { useEffect, useState } from 'react' +import { BrowserRouter, Link, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom' +import { App, ConfigProvider, Layout, Menu, Typography, Button, Tag, Space, Spin, type MenuProps } from 'antd' +import { + AccountBookOutlined, + ApiOutlined, + DashboardOutlined, + FileSearchOutlined, + LogoutOutlined, + SettingOutlined, + ShopOutlined, + UserOutlined, +} from '@ant-design/icons' +import { getMe, logout, type CurrentMember, getStoredToken } from './api' import Dashboard from './pages/Dashboard' import Sites from './pages/Sites' import Rules from './pages/Rules' @@ -8,11 +19,14 @@ import Payments from './pages/Payments' import Accounts from './pages/Accounts' import Settlements from './pages/Settlements' import Orders from './pages/Orders' +import Login from './pages/Login' import './style.css' -const MENU_ITEMS = [ +type MenuItem = Required['items'][number] & { superadminOnly?: boolean } + +const ALL_MENU_ITEMS: MenuItem[] = [ { key: '/', icon: , label: 仪表盘 }, - { key: '/sites', icon: , label: 分销网站 }, + { key: '/sites', icon: , label: 分销网站, superadminOnly: true }, { key: '/sold', icon: , label: 售出账号 }, { key: '/receive', icon: , label: 分销收号 }, { key: '/settlements', icon: , label: 结算管理 }, @@ -21,45 +35,174 @@ const MENU_ITEMS = [ { key: '/rules', icon: , label: 比例与税费 }, ] -function pathToSelectedKey(pathname: string): string { - return MENU_ITEMS.some((item) => item.key === pathname) ? pathname : '/' +function MainLayout({ member, onLogout }: { member: CurrentMember; onLogout: () => void }) { + const location = useLocation() + const menuItems = ALL_MENU_ITEMS.filter((item) => !item.superadminOnly || member.is_superadmin) + + const selectedKey = menuItems.some((item) => item.key === location.pathname) + ? location.pathname + : '/' + + const roleLabel = member.is_superadmin + ? '平台超级管理员' + : member.member_type === 'operator' + ? '分销运营' + : member.member_type === 'finance' + ? '分销财务' + : '分销成员' + + return ( + + +
+ + HFB 分销后台 +
+ + + + +
+ {member.is_superadmin ? ( + + 平台全局管理模式 + + ) : ( + + {member.site_name || '当前分销站点'} · {member.domain || member.site_code} + + )} + + 主站 hao858.com · 分销管理系统 + +
+
+ +
+ + + {member.nickname || member.username} + + {roleLabel} +
+ +
+
+
+ + + } /> + {member.is_superadmin && } />} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + +
+ + ) +} + +function AppContent() { + const [member, setMember] = useState(null) + const [loading, setLoading] = useState(true) + const navigate = useNavigate() + const location = useLocation() + + useEffect(() => { + let active = true + const checkAuth = async () => { + // If no token exists and no session, skip getMe + if (!getStoredToken() && location.pathname === '/login') { + if (active) setLoading(false) + return + } + try { + const profile = await getMe() + if (active) { + setMember(profile) + if (location.pathname === '/login') { + navigate('/', { replace: true }) + } + } + } catch { + if (active) { + setMember(null) + if (location.pathname !== '/login') { + navigate('/login', { replace: true }) + } + } + } finally { + if (active) setLoading(false) + } + } + checkAuth() + return () => { + active = false + } + }, [location.pathname, navigate]) + + const handleLogout = async () => { + await logout() + setMember(null) + navigate('/login', { replace: true }) + } + + const handleLoginSuccess = (current: CurrentMember) => { + setMember(current) + navigate('/', { replace: true }) + } + + if (loading) { + return ( +
+ +
+ ) + } + + if (location.pathname === '/login') { + return + } + + if (!member) { + return + } + + return } export default function AppShell() { return ( - + - - -
HFB 分销后台
- - - - - 主站 hao858.com · 分销子域名 distribution.hao858.com - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - + diff --git a/distribution-admin/src/api.ts b/distribution-admin/src/api.ts index afbf63b..ba25d72 100644 --- a/distribution-admin/src/api.ts +++ b/distribution-admin/src/api.ts @@ -1,28 +1,60 @@ import axios from 'axios' -import { Modal } from 'antd' + +const TOKEN_KEY = 'hfb_dist_access_token' + +export function getStoredToken(): string | null { + try { + return localStorage.getItem(TOKEN_KEY) + } catch { + return null + } +} + +export function setStoredToken(token: string): void { + try { + localStorage.setItem(TOKEN_KEY, token) + } catch { + // ignore local storage errors + } +} + +export function clearStoredToken(): void { + try { + localStorage.removeItem(TOKEN_KEY) + } catch { + // ignore local storage errors + } +} const api = axios.create({ baseURL: '/api', withCredentials: true }) -interface ApiResponse { data: T; code?: number; message?: string } - -let authNoticeShown = false +api.interceptors.request.use((config) => { + const token = getStoredToken() + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) api.interceptors.response.use( (resp) => resp, (error) => { - // 后台鉴权依赖主站 HttpOnly cookie;401 说明未登录或会话过期。 - if (error?.response?.status === 401 && !authNoticeShown) { - authNoticeShown = true - Modal.warning({ - title: '登录状态失效', - content: '请先在主站后台登录管理员账号,再返回本页面。', - onOk: () => { authNoticeShown = false }, - }) + if (error?.response?.status === 401) { + clearStoredToken() + if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) { + window.location.href = '/login' + } } return Promise.reject(error) }, ) +export interface ApiResponse { + data: T + code?: string | number + message?: string +} + export function extract(resp: { data: ApiResponse }): T { return resp.data.data } @@ -31,6 +63,49 @@ export function formatMoney(cent: number | undefined | null): string { return `¥${((cent ?? 0) / 100).toFixed(2)}` } +export interface CurrentMember { + admin_id: number + username: string + nickname: string + is_superadmin: boolean + site_id?: number + site_code?: string + site_name?: string + domain?: string + member_type?: string +} + +export interface LoginResult { + token: string + refresh_token?: string + member: CurrentMember +} + +export async function login(username: string, password: string): Promise { + const resp = await api.post>('/distribution/auth/login', { + username, + password, + }) + const res = extract(resp) + if (res.token) { + setStoredToken(res.token) + } + return res +} + +export async function getMe(): Promise { + const resp = await api.get>('/distribution/auth/me') + return extract(resp) +} + +export async function logout(): Promise { + try { + await api.post('/distribution/auth/logout') + } finally { + clearStoredToken() + } +} + export interface Site { id: number site_code: string @@ -123,6 +198,33 @@ export interface SettlementItem { status: string } +export interface OnboardSitePayload { + site_code: string + site_name: string + domain: string + admin_username: string + admin_password: string + admin_nickname?: string + member_type?: string + display_account_enabled?: boolean + tax_enabled?: boolean + tax_rate_bps?: number + distributor_receive_ratio?: number + platform_sell_ratio?: number + service_fee_value?: number +} + +export interface OnboardSiteResult { + site: Site + rule: Rule + member: CurrentMember +} + +export async function onboardSite(payload: OnboardSitePayload): Promise { + const resp = await api.post>('/admin/distribution/sites/onboard', payload) + return extract(resp) +} + export async function listSites(): Promise { const resp = await api.get>('/admin/distribution/sites') return extract(resp).items ?? [] diff --git a/distribution-admin/src/pages/Login.tsx b/distribution-admin/src/pages/Login.tsx new file mode 100644 index 0000000..d5ed43c --- /dev/null +++ b/distribution-admin/src/pages/Login.tsx @@ -0,0 +1,107 @@ +import { useState } from 'react' +import { Card, Form, Input, Button, Alert, Typography, Space } from 'antd' +import { UserOutlined, LockOutlined, SafetyCertificateOutlined } from '@ant-design/icons' +import { login, type CurrentMember } from '../api' + +interface LoginProps { + onSuccess: (member: CurrentMember) => void +} + +export default function Login({ onSuccess }: LoginProps) { + const [loading, setLoading] = useState(false) + const [errorMsg, setErrorMsg] = useState('') + + const handleSubmit = async (values: { username: string; password: string }) => { + setLoading(true) + setErrorMsg('') + try { + const res = await login(values.username.trim(), values.password) + onSuccess(res.member) + } catch (err: unknown) { + const errObj = err as { response?: { data?: { message?: string } } } + const msg = errObj?.response?.data?.message || '登录失败,请检查用户名和密码' + setErrorMsg(msg) + } finally { + setLoading(false) + } + } + + return ( +
+ +
+
+ +
+ + HFB 分销系统 + + + 独立分销商 · 多站点综合管理后台 + +
+ + {errorMsg && ( + setErrorMsg('')} + style={{ marginBottom: 20 }} + /> + )} + +
+ + } + placeholder="分销账号 / 管理员用户名" + autoComplete="username" + /> + + + + } + placeholder="登录密码" + autoComplete="current-password" + /> + + + + + +
+ +
+ ·}> + + 租户多维隔离 + + + 独立结算分成 + + + 安全双模鉴权 + + +
+
+
+ ) +} diff --git a/distribution-admin/src/pages/Rules.tsx b/distribution-admin/src/pages/Rules.tsx index b947598..ea25b6e 100644 --- a/distribution-admin/src/pages/Rules.tsx +++ b/distribution-admin/src/pages/Rules.tsx @@ -26,8 +26,17 @@ export default function Rules() { } useEffect(() => { - listSites().then(setSites).catch(() => message.error('站点加载失败')) - void load(undefined) + listSites() + .then((rows) => { + setSites(rows) + if (rows.length === 1) { + setSiteID(rows[0].id) + void load(rows[0].id) + } else { + void load(undefined) + } + }) + .catch(() => message.error('站点加载失败')) }, []) async function toggleStatus(rule: Rule) { @@ -49,6 +58,7 @@ export default function Rules() { allowClear placeholder="分销网站" style={{ width: 200 }} + value={siteID} options={sites.map((site) => ({ label: site.site_name, value: site.id }))} onChange={(value) => { setSiteID(value) diff --git a/distribution-admin/src/pages/Sites.tsx b/distribution-admin/src/pages/Sites.tsx index 4b142a6..4ccf298 100644 --- a/distribution-admin/src/pages/Sites.tsx +++ b/distribution-admin/src/pages/Sites.tsx @@ -1,39 +1,336 @@ import { useEffect, useState } from 'react' -import { Card, Table, Typography, Space, Tag, message } from 'antd' -import { listSites, type Site } from '../api' +import { + Card, + Table, + Typography, + Space, + Tag, + Button, + Modal, + Form, + Input, + InputNumber, + Select, + Switch, + message, + Divider, + Descriptions, +} from 'antd' +import { PlusOutlined, ShopOutlined, CopyOutlined } from '@ant-design/icons' +import { listSites, onboardSite, type Site, type OnboardSitePayload, type OnboardSiteResult } from '../api' -export default function Sites() { +export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean }) { const [items, setItems] = useState([]) - useEffect(() => { + const [loading, setLoading] = useState(false) + const [modalOpen, setModalOpen] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [successResult, setSuccessResult] = useState(null) + const [form] = Form.useForm() + + const loadData = () => { + setLoading(true) listSites() .then(setItems) .catch(() => message.error('站点加载失败')) + .finally(() => setLoading(false)) + } + + useEffect(() => { + loadData() }, []) + + const handleOpenModal = () => { + form.resetFields() + form.setFieldsValue({ + member_type: 'operator', + distributor_receive_ratio: 3.5, + platform_sell_ratio: 4.0, + service_fee_value: 2.5, + display_account_enabled: true, + tax_enabled: false, + tax_rate_bps: 100, + }) + setModalOpen(true) + } + + const handleOnboardSubmit = async (values: any) => { + setSubmitting(true) + try { + const payload: OnboardSitePayload = { + site_code: values.site_code.trim(), + site_name: values.site_name.trim(), + domain: values.domain.trim(), + admin_username: values.admin_username.trim(), + admin_password: values.admin_password, + admin_nickname: values.admin_nickname?.trim() || undefined, + member_type: values.member_type, + display_account_enabled: values.display_account_enabled, + tax_enabled: values.tax_enabled, + tax_rate_bps: values.tax_enabled ? values.tax_rate_bps : 0, + distributor_receive_ratio: Number(values.distributor_receive_ratio), + platform_sell_ratio: Number(values.platform_sell_ratio), + service_fee_value: Number(values.service_fee_value), + } + const res = await onboardSite(payload) + message.success('分销站点及分销商管理员开通成功!') + setModalOpen(false) + setSuccessResult(res) + loadData() + } catch (err: any) { + const msg = err?.response?.data?.message || '开通分销商失败,请检查站点标识或管理员用户名是否重复' + message.error(msg) + } finally { + setSubmitting(false) + } + } + return ( - - 分销网站 + +
+
+ + 分销网站管理 + + + 管理全平台合作分销站点、域名配置与分销商管理员账号 + +
+ {isSuperadmin && ( + + )} +
+ {v} }, + { + title: '站点名称', + dataIndex: 'site_name', + render: (v: string) => ( + + + {v} + + ), + }, + { title: '站点标识', dataIndex: 'site_code', render: (v: string) => {v} }, + { title: '绑定域名', dataIndex: 'domain' }, + { + title: '运行状态', + dataIndex: 'status', + render: (v: string) => ( + + {v === 'active' ? '正常运营' : v} + + ), + }, { title: '账号展示', dataIndex: 'display_account_enabled', render: (v: boolean) => (v ? 开启 : 关闭), }, { - title: '税费', - render: (_: unknown, row: Site) => (row.tax_enabled ? `${row.tax_rate_bps / 100}%` : '关闭'), + title: '税费设定', + render: (_: unknown, row: Site) => + row.tax_enabled ? {row.tax_rate_bps / 100}% : 不征税, + }, + { + title: '创建时间', + dataIndex: 'created_at', + render: (v: string) => (v ? new Date(v).toLocaleDateString() : '-'), }, ]} /> + + {/* 开通分销商 Modal */} + setModalOpen(false)} + footer={null} + width={680} + destroyOnClose + > +
+ + 1. 站点基础信息 + + + + + + + + + + + + + + + + + 2. 分销商管理员账号 + + + + + + + + + + + + + + +