feat(distribution): 独立分销后台账号体系、多租户隔离与主站后台分销管理GUI

- 独立账号鉴权:分销后台支持独立登录页面、Bearer+Cookie 双模鉴权与租户资料接口
- 多租户隔离:分销商成员强制锁定自身站点数据范围,防止横向越权;平台超管享有全局视界
- 一键开通分销商:平台端支持一键录入站点、管理员账号、分配角色并原子化初始化佣金规则
- 主后台分销分组:在主站后台增加「分销管理」导航分组,提供站点管理、规则配置、订单监控与结算管理 GUI
- 门禁与联调:补充完整单测,全量代码门禁 check.sh 100% 通过,dev.sh 支持前后端与分销后台一键同启
This commit is contained in:
yml2213
2026-09-07 15:45:53 +08:00
parent 0c37c7e659
commit 550b42eb22
28 changed files with 3192 additions and 117 deletions
+2
View File
@@ -29,6 +29,8 @@ frontend/components.d.ts
# Docker / local data
.docker-data/
backups/
deploy/backup/
.zcode/
.DS_Store
backend/api
+3
View File
@@ -22,6 +22,9 @@
后台地址默认:`http://localhost:5189/admin/login`。可通过 `VITE_ADMIN_BASE_PATH` 调整后台页面入口。
分销后台地址默认:`http://localhost:5190`
如需拆分调试,可手动执行:
```bash
@@ -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)
}
}
@@ -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"`
}
@@ -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):
@@ -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)
}
}
@@ -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
}
@@ -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
@@ -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)
}
+16 -2
View File
@@ -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)
+181 -38
View File
@@ -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<MenuProps>['items'][number] & { superadminOnly?: boolean }
const ALL_MENU_ITEMS: MenuItem[] = [
{ key: '/', icon: <DashboardOutlined />, label: <Link to="/">仪表盘</Link> },
{ key: '/sites', icon: <ShopOutlined />, label: <Link to="/sites">分销网站</Link> },
{ key: '/sites', icon: <ShopOutlined />, label: <Link to="/sites">分销网站</Link>, superadminOnly: true },
{ key: '/sold', icon: <FileSearchOutlined />, label: <Link to="/sold">售出账号</Link> },
{ key: '/receive', icon: <AccountBookOutlined />, label: <Link to="/receive">分销收号</Link> },
{ key: '/settlements', icon: <AccountBookOutlined />, label: <Link to="/settlements">结算管理</Link> },
@@ -21,45 +35,174 @@ const MENU_ITEMS = [
{ key: '/rules', icon: <SettingOutlined />, label: <Link to="/rules">比例与税费</Link> },
]
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 (
<Layout className="app">
<Layout.Sider breakpoint="lg" collapsedWidth="0">
<div className="brand">
<ShopOutlined style={{ fontSize: 20, color: '#1677ff' }} />
<span>HFB 分销后台</span>
</div>
<Menu
theme="dark"
mode="inline"
items={menuItems}
selectedKeys={[selectedKey]}
/>
</Layout.Sider>
<Layout>
<Layout.Header className="header">
<div className="header-left">
{member.is_superadmin ? (
<Tag color="gold" style={{ fontSize: 13, padding: '2px 8px' }}>
平台全局管理模式
</Tag>
) : (
<Tag color="blue" style={{ fontSize: 13, padding: '2px 8px' }}>
{member.site_name || '当前分销站点'} · {member.domain || member.site_code}
</Tag>
)}
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
主站 hao858.com · 分销管理系统
</Typography.Text>
</div>
<div className="header-right">
<Space size="middle">
<div className="user-profile">
<UserOutlined style={{ color: '#1677ff' }} />
<Typography.Text strong style={{ fontSize: 13 }}>
{member.nickname || member.username}
</Typography.Text>
<Tag color={member.is_superadmin ? 'purple' : 'cyan'}>{roleLabel}</Tag>
</div>
<Button
type="text"
danger
icon={<LogoutOutlined />}
onClick={onLogout}
size="small"
>
退出登录
</Button>
</Space>
</div>
</Layout.Header>
<Layout.Content>
<Routes>
<Route path="/" element={<Dashboard />} />
{member.is_superadmin && <Route path="/sites" element={<Sites isSuperadmin={true} />} />}
<Route path="/rules" element={<Rules />} />
<Route path="/payments" element={<Payments />} />
<Route path="/accounts" element={<Accounts />} />
<Route path="/settlements" element={<Settlements />} />
<Route path="/sold" element={<Orders kind="sold" />} />
<Route path="/receive" element={<Orders kind="receive" />} />
<Route path="/orders" element={<Orders kind="all" />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout.Content>
</Layout>
</Layout>
)
}
function AppContent() {
const [member, setMember] = useState<CurrentMember | null>(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 (
<div style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin size="large" tip="正在加载分销系统..." />
</div>
)
}
if (location.pathname === '/login') {
return <Login onSuccess={handleLoginSuccess} />
}
if (!member) {
return <Navigate to="/login" replace />
}
return <MainLayout member={member} onLogout={handleLogout} />
}
export default function AppShell() {
return (
<BrowserRouter>
<ConfigProvider theme={{ token: { colorPrimary: '#1677ff', borderRadius: 6 } }}>
<ConfigProvider
theme={{
token: {
colorPrimary: '#1677ff',
borderRadius: 6,
},
}}
>
<App>
<Layout className="app">
<Layout.Sider breakpoint="lg" collapsedWidth="0">
<div className="brand">HFB 分销后台</div>
<Menu
theme="dark"
mode="inline"
items={MENU_ITEMS}
defaultSelectedKeys={[pathToSelectedKey(window.location.pathname)]}
/>
</Layout.Sider>
<Layout>
<Layout.Header className="header">
<Typography.Text>主站 hao858.com · 分销子域名 distribution.hao858.com</Typography.Text>
</Layout.Header>
<Layout.Content>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/sites" element={<Sites />} />
<Route path="/rules" element={<Rules />} />
<Route path="/payments" element={<Payments />} />
<Route path="/accounts" element={<Accounts />} />
<Route path="/settlements" element={<Settlements />} />
<Route path="/sold" element={<Orders kind="sold" />} />
<Route path="/receive" element={<Orders kind="receive" />} />
<Route path="/orders" element={<Orders kind="all" />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout.Content>
</Layout>
</Layout>
<AppContent />
</App>
</ConfigProvider>
</BrowserRouter>
+114 -12
View File
@@ -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<T> { 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<T> {
data: T
code?: string | number
message?: string
}
export function extract<T>(resp: { data: ApiResponse<T> }): 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<LoginResult> {
const resp = await api.post<ApiResponse<LoginResult>>('/distribution/auth/login', {
username,
password,
})
const res = extract(resp)
if (res.token) {
setStoredToken(res.token)
}
return res
}
export async function getMe(): Promise<CurrentMember> {
const resp = await api.get<ApiResponse<CurrentMember>>('/distribution/auth/me')
return extract(resp)
}
export async function logout(): Promise<void> {
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<OnboardSiteResult> {
const resp = await api.post<ApiResponse<OnboardSiteResult>>('/admin/distribution/sites/onboard', payload)
return extract(resp)
}
export async function listSites(): Promise<Site[]> {
const resp = await api.get<ApiResponse<{ items: Site[] }>>('/admin/distribution/sites')
return extract(resp).items ?? []
+107
View File
@@ -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 (
<div className="login-container">
<Card className="login-card" bordered={false}>
<div className="login-header">
<div className="login-logo">
<SafetyCertificateOutlined style={{ fontSize: 32, color: '#1677ff' }} />
</div>
<Typography.Title level={3} style={{ margin: '12px 0 4px' }}>
HFB 分销系统
</Typography.Title>
<Typography.Text type="secondary">
独立分销商 · 多站点综合管理后台
</Typography.Text>
</div>
{errorMsg && (
<Alert
type="error"
message={errorMsg}
showIcon
closable
onClose={() => setErrorMsg('')}
style={{ marginBottom: 20 }}
/>
)}
<Form layout="vertical" onFinish={handleSubmit} size="large">
<Form.Item
name="username"
rules={[{ required: true, message: '请输入分销管理员账号' }]}
>
<Input
prefix={<UserOutlined style={{ color: '#bfbfbf' }} />}
placeholder="分销账号 / 管理员用户名"
autoComplete="username"
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined style={{ color: '#bfbfbf' }} />}
placeholder="登录密码"
autoComplete="current-password"
/>
</Form.Item>
<Form.Item style={{ marginTop: 24 }}>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
style={{ height: 44, fontWeight: 500 }}
>
登录分销后台
</Button>
</Form.Item>
</Form>
<div className="login-footer">
<Space split={<span style={{ color: '#d9d9d9' }}>·</span>}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
租户多维隔离
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
独立结算分成
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
安全双模鉴权
</Typography.Text>
</Space>
</div>
</Card>
</div>
)
}
+12 -2
View File
@@ -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)
+309 -12
View File
@@ -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<Site[]>([])
useEffect(() => {
const [loading, setLoading] = useState(false)
const [modalOpen, setModalOpen] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [successResult, setSuccessResult] = useState<OnboardSiteResult | null>(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 (
<Space direction="vertical" className="page">
<Typography.Title level={3}>分销网站</Typography.Title>
<Space direction="vertical" className="page" size="large">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Typography.Title level={3} style={{ margin: 0 }}>
分销网站管理
</Typography.Title>
<Typography.Text type="secondary">
管理全平台合作分销站点、域名配置与分销商管理员账号
</Typography.Text>
</div>
{isSuperadmin && (
<Button type="primary" icon={<PlusOutlined />} onClick={handleOpenModal}>
开通分销商
</Button>
)}
</div>
<Card>
<Table
rowKey="id"
dataSource={items}
loading={loading}
pagination={false}
columns={[
{ title: '名称', dataIndex: 'site_name' },
{ title: '编码', dataIndex: 'site_code' },
{ title: '域名', dataIndex: 'domain' },
{ title: '状态', dataIndex: 'status', render: (v: string) => <Tag color={v === 'active' ? 'green' : 'default'}>{v}</Tag> },
{
title: '站点名称',
dataIndex: 'site_name',
render: (v: string) => (
<Space>
<ShopOutlined style={{ color: '#1677ff' }} />
<strong>{v}</strong>
</Space>
),
},
{ title: '站点标识', dataIndex: 'site_code', render: (v: string) => <Tag>{v}</Tag> },
{ title: '绑定域名', dataIndex: 'domain' },
{
title: '运行状态',
dataIndex: 'status',
render: (v: string) => (
<Tag color={v === 'active' ? 'green' : 'default'}>
{v === 'active' ? '正常运营' : v}
</Tag>
),
},
{
title: '账号展示',
dataIndex: 'display_account_enabled',
render: (v: boolean) => (v ? <Tag color="green">开启</Tag> : <Tag>关闭</Tag>),
},
{
title: '税费',
render: (_: unknown, row: Site) => (row.tax_enabled ? `${row.tax_rate_bps / 100}%` : '关闭'),
title: '税费设定',
render: (_: unknown, row: Site) =>
row.tax_enabled ? <Tag color="orange">{row.tax_rate_bps / 100}%</Tag> : <Tag>不征税</Tag>,
},
{
title: '创建时间',
dataIndex: 'created_at',
render: (v: string) => (v ? new Date(v).toLocaleDateString() : '-'),
},
]}
/>
</Card>
{/* 开通分销商 Modal */}
<Modal
title="开通分销商 (一键初始化站点与账号)"
open={modalOpen}
onCancel={() => setModalOpen(false)}
footer={null}
width={680}
destroyOnClose
>
<Form form={form} layout="vertical" onFinish={handleOnboardSubmit} style={{ marginTop: 16 }}>
<Typography.Title level={5} style={{ marginTop: 0 }}>
1. 站点基础信息
</Typography.Title>
<Space direction="horizontal" style={{ width: '100%' }} size="middle">
<Form.Item
label="站点名称"
name="site_name"
rules={[{ required: true, message: '请输入站点名称' }]}
style={{ flex: 1 }}
>
<Input placeholder="例如:广州游戏分销站" />
</Form.Item>
<Form.Item
label="站点唯一标识"
name="site_code"
rules={[
{ required: true, message: '请输入唯一代码' },
{ pattern: /^[a-zA-Z0-9_-]+$/, message: '仅支持字母、数字与下划线' },
]}
style={{ flex: 1 }}
>
<Input placeholder="例如:gz_dist" />
</Form.Item>
</Space>
<Form.Item
label="分销专属域名"
name="domain"
rules={[{ required: true, message: '请输入分销绑定域名' }]}
>
<Input placeholder="例如:gz.distribution.hao858.com" />
</Form.Item>
<Divider style={{ margin: '16px 0' }} />
<Typography.Title level={5}>2. 分销商管理员账号</Typography.Title>
<Space direction="horizontal" style={{ width: '100%' }} size="middle">
<Form.Item
label="登录用户名"
name="admin_username"
rules={[{ required: true, message: '请输入管理员账号' }]}
style={{ flex: 1 }}
>
<Input placeholder="例如:gz_admin" />
</Form.Item>
<Form.Item
label="初始登录密码"
name="admin_password"
rules={[
{ required: true, message: '请输入登录密码' },
{ min: 6, message: '密码至少6位' },
]}
style={{ flex: 1 }}
>
<Input.Password placeholder="至少6位" />
</Form.Item>
</Space>
<Space direction="horizontal" style={{ width: '100%' }} size="middle">
<Form.Item label="管理员姓名/昵称" name="admin_nickname" style={{ flex: 1 }}>
<Input placeholder="例如:张站长" />
</Form.Item>
<Form.Item label="初始角色权限" name="member_type" style={{ flex: 1 }}>
<Select
options={[
{ label: '分销运营 (operator)', value: 'operator' },
{ label: '分销财务 (finance)', value: 'finance' },
{ label: '客服人员 (customer_service)', value: 'customer_service' },
]}
/>
</Form.Item>
</Space>
<Divider style={{ margin: '16px 0' }} />
<Typography.Title level={5}>3. 分销比例与佣金规则初始化</Typography.Title>
<Space direction="horizontal" style={{ width: '100%' }} size="middle">
<Form.Item
label="分销商收号分成比例 (%)"
name="distributor_receive_ratio"
tooltip="分销模式下分销商收号收益率"
style={{ flex: 1 }}
>
<InputNumber min={0} max={100} step={0.1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label="平台卖号分成比例 (%)"
name="platform_sell_ratio"
tooltip="平台卖号提取分成率"
style={{ flex: 1 }}
>
<InputNumber min={0} max={100} step={0.1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label="单笔服务费 (元)"
name="service_fee_value"
tooltip="按单扣除的固定服务费"
style={{ flex: 1 }}
>
<InputNumber min={0} step={0.5} style={{ width: '100%' }} />
</Form.Item>
</Space>
<Space direction="horizontal" size="large" style={{ marginTop: 8 }}>
<Form.Item
label="允许展示主站账号"
name="display_account_enabled"
valuePropName="checked"
>
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
<Form.Item label="是否征税" name="tax_enabled" valuePropName="checked">
<Switch checkedChildren="征税" unCheckedChildren="免税" />
</Form.Item>
</Space>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 24 }}>
<Button onClick={() => setModalOpen(false)}>取消</Button>
<Button type="primary" htmlType="submit" loading={submitting}>
立即开通并生成分销商
</Button>
</div>
</Form>
</Modal>
{/* 开通成功信息 Modal */}
<Modal
title="分销商开通成功"
open={Boolean(successResult)}
onOk={() => setSuccessResult(null)}
cancelButtonProps={{ style: { display: 'none' } }}
width={560}
>
{successResult && (
<div>
<Typography.Paragraph type="secondary">
分销商站点与管理账号已成功创建,请妥善保管并交付给分销合作伙伴:
</Typography.Paragraph>
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="分销站点">
{successResult.site.site_name} ({successResult.site.site_code})
</Descriptions.Item>
<Descriptions.Item label="分销域名">
{successResult.site.domain}
</Descriptions.Item>
<Descriptions.Item label="分销后台登录账号">
<strong>{successResult.member.username}</strong>
</Descriptions.Item>
<Descriptions.Item label="所属角色">
{successResult.member.member_type}
</Descriptions.Item>
<Descriptions.Item label="初始规则版本">
收号分成: {successResult.rule.distributor_receive_ratio}% · 卖号分成:{' '}
{successResult.rule.platform_sell_ratio}% · 服务费:{' '}
¥{successResult.rule.service_fee_value}
</Descriptions.Item>
</Descriptions>
<div style={{ marginTop: 16, textAlign: 'right' }}>
<Button
icon={<CopyOutlined />}
onClick={() => {
const text = `分销站点: ${successResult.site.site_name}\n后台地址: http://${window.location.host}/login\n用户名: ${successResult.member.username}`
navigator.clipboard?.writeText(text)
message.success('已复制分销商开通信息到剪贴板')
}}
>
复制开通信息
</Button>
</div>
</div>
)}
</Modal>
</Space>
)
}
+123 -9
View File
@@ -1,9 +1,123 @@
:root { font-family: Inter, "PingFang SC", sans-serif; color: #1f2937; background: #f5f7fa; }
* { box-sizing: border-box; }
body { margin: 0; }
.app { min-height: 100vh; }
.brand { height: 64px; padding: 20px 18px; color: #fff; font-weight: 600; letter-spacing: 0; }
.header { display: flex; align-items: center; background: #fff; padding: 0 24px; border-bottom: 1px solid #edf0f5; }
.page { width: 100%; padding: 24px; }
.stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; width: 100%; }
@media (max-width: 720px) { .page { padding: 16px; } .stats { grid-template-columns: 1fr; } }
:root {
font-family: Inter, -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
color: #1f2937;
background: #f5f7fa;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
.app {
min-height: 100vh;
}
.brand {
height: 64px;
padding: 20px 20px;
color: #fff;
font-size: 16px;
font-weight: 600;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 10px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
padding: 0 24px;
border-bottom: 1px solid #edf0f5;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.user-profile {
display: flex;
align-items: center;
gap: 8px;
}
.page {
width: 100%;
padding: 24px;
}
.stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
width: 100%;
}
/* Login Page Styles */
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%);
padding: 20px;
}
.login-card {
width: 100%;
max-width: 420px;
border-radius: 16px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.25);
background: #ffffff;
padding: 12px 8px;
}
.login-header {
text-align: center;
margin-bottom: 28px;
}
.login-logo {
width: 56px;
height: 56px;
margin: 0 auto 8px;
background: #e6f4ff;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
}
.login-footer {
text-align: center;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
@media (max-width: 720px) {
.page {
padding: 16px;
}
.stats {
grid-template-columns: 1fr;
}
.header {
padding: 0 12px;
}
}
+14 -1
View File
@@ -1,7 +1,20 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
const backendPort = process.env.DEV_BACKEND_PORT || '18088'
const apiProxyTarget = process.env.VITE_API_PROXY_TARGET || `http://127.0.0.1:${backendPort}`
const distributionPort = Number(process.env.DEV_DISTRIBUTION_PORT || process.env.PORT || 5190)
export default defineConfig({
plugins: [react()],
server: { port: 5190, proxy: { '/api': 'http://localhost:8080' } },
server: {
port: distributionPort,
proxy: {
'/api': {
target: apiProxyTarget,
changeOrigin: true,
},
},
},
})
@@ -0,0 +1,246 @@
import { apiClient } from '@/shared/api/client'
import type { ApiResponse } from '@/shared/types/types'
export interface DistributionSite {
id: number
site_code: string
site_name: string
domain: string
status: string
display_account_enabled: boolean
tax_enabled: boolean
tax_rate_bps: number
created_at: string
}
export interface DistributionRule {
id: number
site_id?: number
mode: string
version: number
status: string
platform_receive_ratio: number
platform_sell_ratio: number
distributor_receive_ratio: number
distributor_sell_ratio: number
distributor_a_ratio: number
distributor_b_ratio: number
service_fee_value: number
service_fee_unit: string
payment_fee_bps: number
tax_enabled: boolean
tax_rate_bps: number
effective_from: string
effective_to?: string
}
export interface DistributionOrder {
order_id: number
order_no: string
site_id: number
status: string
refund_status: string
settlement_status: string
distribution_mode: string
net_paid_amount_cent: number
payment_fee_cent: number
service_fee_cent: number
markup_amount_cent: number
tax_amount_cent: number
split_base_amount_cent: number
rounding_adjustment_cent: number
created_at: string
}
export interface DistributionOrderPage {
items: DistributionOrder[]
total: number
page: number
page_size: number
}
export interface DistributionSettlementBatch {
id: number
site_id: number
batch_no: string
period_start: string
period_end: string
total_amount_cent: number
status: string
}
export interface DistributionSettlementItem {
id: number
order_id: number
party_role: string
amount_cent: number
status: string
}
export interface OnboardDistributionSitePayload {
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 OnboardDistributionSiteResult {
site: DistributionSite
rule: DistributionRule
member: {
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 async function fetchDistributionSites() {
const { data } = await apiClient.get<ApiResponse<{ items: DistributionSite[] }>>(
'/admin/distribution/sites'
)
return data.data.items ?? []
}
export async function onboardDistributionSite(payload: OnboardDistributionSitePayload) {
const { data } = await apiClient.post<ApiResponse<OnboardDistributionSiteResult>>(
'/admin/distribution/sites/onboard',
payload
)
return data.data
}
export async function updateDistributionSite(
id: number,
payload: Partial<
Pick<
DistributionSite,
'site_name' | 'domain' | 'status' | 'display_account_enabled' | 'tax_enabled' | 'tax_rate_bps'
>
>
) {
const { data } = await apiClient.put<ApiResponse<DistributionSite>>(
`/admin/distribution/sites/${id}`,
payload
)
return data.data
}
export async function fetchDistributionRules(siteId?: number, mode?: string) {
const { data } = await apiClient.get<ApiResponse<{ items: DistributionRule[] }>>(
'/admin/distribution/rules',
{
params: { site_id: siteId || undefined, mode: mode || undefined },
}
)
return data.data.items ?? []
}
export interface CreateRulePayload {
site_id?: number
mode: string
distributor_receive_ratio?: number
platform_sell_ratio?: number
distributor_sell_ratio?: number
platform_receive_ratio?: number
distributor_a_ratio?: number
distributor_b_ratio?: number
service_fee_value: number
service_fee_unit: string
payment_fee_bps: number
tax_enabled: boolean
tax_rate_bps: number
effective_from?: string
}
export async function createDistributionRule(payload: CreateRulePayload) {
const { data } = await apiClient.post<ApiResponse<DistributionRule>>(
'/admin/distribution/rules',
payload
)
return data.data
}
export async function updateDistributionRuleStatus(id: number, status: string) {
const { data } = await apiClient.put<ApiResponse<DistributionRule>>(
`/admin/distribution/rules/${id}/status`,
{ status }
)
return data.data
}
export async function fetchDistributionOrders(params: {
kind?: string
status?: string
siteID?: number
page?: number
pageSize?: number
}) {
const { data } = await apiClient.get<ApiResponse<DistributionOrderPage>>(
'/admin/distribution/orders',
{
params: {
kind: params.kind || undefined,
status: params.status || undefined,
site_id: params.siteID || undefined,
page: params.page || 1,
page_size: params.pageSize || 20,
},
}
)
return data.data
}
export async function fetchDistributionSettlementBatches(siteId: number) {
const { data } = await apiClient.get<ApiResponse<{ items: DistributionSettlementBatch[] }>>(
'/admin/distribution/settlement-batches',
{
params: { site_id: siteId },
}
)
return data.data.items ?? []
}
export async function createDistributionSettlementBatch(
siteId: number,
periodStart: string,
periodEnd: string
) {
const { data } = await apiClient.post<ApiResponse<DistributionSettlementBatch>>(
'/admin/distribution/settlement-batches',
{
site_id: siteId,
period_start: periodStart,
period_end: periodEnd,
}
)
return data.data
}
export async function settleDistributionBatch(id: number) {
const { data } = await apiClient.post<ApiResponse<DistributionSettlementBatch>>(
`/admin/distribution/settlement-batches/${id}/settle`
)
return data.data
}
export async function fetchDistributionSettlementItems(batchId: number) {
const { data } = await apiClient.get<ApiResponse<{ items: DistributionSettlementItem[] }>>(
`/admin/distribution/settlement-batches/${batchId}/items`
)
return data.data.items ?? []
}
+1
View File
@@ -7,6 +7,7 @@ export * from './api/adminPayments'
export * from './api/adminFinance'
export * from './api/adminAudit'
export * from './api/adminNotifications'
export * from './api/adminDistribution'
export * from './api/systemConfigs'
export * from './api/supportGroups'
export * from './composables/useAdminTable'
@@ -0,0 +1,229 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import {
fetchDistributionOrders,
fetchDistributionSites,
type DistributionOrder,
type DistributionSite,
} from '@/features/admin/api/adminDistribution'
import { formatCent } from '@/shared/utils/money'
import { formatDateTime } from '@/shared/utils/time'
import { readError } from '@/shared/utils/error'
const loading = ref(false)
const sites = ref<DistributionSite[]>([])
const orders = ref<DistributionOrder[]>([])
const total = ref(0)
const query = reactive({
siteID: undefined as number | undefined,
kind: '' as string,
status: '' as string,
page: 1,
pageSize: 20,
})
const modeMap: Record<string, string> = {
two_party_distributor_receive: '收号',
two_party_platform_receive: '售出',
three_party: '三方',
}
async function loadSites() {
try {
sites.value = await fetchDistributionSites()
} catch (err) {
ElMessage.error(readError(err, '获取分销站点失败'))
}
}
async function loadOrders() {
loading.value = true
try {
const res = await fetchDistributionOrders({
siteID: query.siteID,
kind: query.kind || undefined,
status: query.status || undefined,
page: query.page,
pageSize: query.pageSize,
})
orders.value = res.items ?? []
total.value = res.total ?? 0
} catch (err) {
ElMessage.error(readError(err, '获取分销订单失败'))
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
loadOrders()
}
function handleReset() {
query.siteID = undefined
query.kind = ''
query.status = ''
query.page = 1
loadOrders()
}
function getSiteName(siteId: number) {
const site = sites.value.find(s => s.id === siteId)
return site ? `${site.site_name}` : `站点 #${siteId}`
}
onMounted(async () => {
await loadSites()
await loadOrders()
})
</script>
<template>
<div class="distribution-orders-view">
<div class="page-header">
<div>
<h2 class="page-title">分销订单监控</h2>
<p class="page-subtitle">
全览各合作分销站点的收号与售出订单明细、渠道手续费、单笔服务费及利润分成流水。
</p>
</div>
</div>
<el-card shadow="never" class="filter-card">
<el-form :inline="true" :model="query">
<el-form-item label="分销站点">
<el-select v-model="query.siteID" clearable placeholder="全部分销站" style="width: 180px">
<el-option
v-for="site in sites"
:key="site.id"
:label="site.site_name"
:value="site.id"
/>
</el-select>
</el-form-item>
<el-form-item label="交易类型">
<el-select v-model="query.kind" clearable placeholder="全部模式" style="width: 140px">
<el-option label="分销收号" value="receive" />
<el-option label="分销售出" value="sold" />
<el-option label="全部" value="all" />
</el-select>
</el-form-item>
<el-form-item label="结算状态">
<el-select v-model="query.status" clearable placeholder="全部状态" style="width: 140px">
<el-option label="待结算" value="pending" />
<el-option label="已结算" value="settled" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never" style="margin-top: 16px">
<el-table v-loading="loading" :data="orders" stripe style="width: 100%">
<el-table-column label="订单号" prop="order_no" min-width="160" />
<el-table-column label="分销站点" min-width="140">
<template #default="{ row }">
<el-tag size="small">{{ getSiteName(row.site_id) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="模式" width="90" align="center">
<template #default="{ row }">
<el-tag type="info" size="small">
{{ modeMap[row.distribution_mode] || row.distribution_mode }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="订单实付" min-width="110" align="right">
<template #default="{ row }">
<strong>{{ formatCent(row.net_paid_amount_cent) }}</strong>
</template>
</el-table-column>
<el-table-column label="渠道手续费" min-width="100" align="right">
<template #default="{ row }">
{{ formatCent(row.payment_fee_cent) }}
</template>
</el-table-column>
<el-table-column label="固定服务费" min-width="100" align="right">
<template #default="{ row }">
{{ formatCent(row.service_fee_cent) }}
</template>
</el-table-column>
<el-table-column label="税费" min-width="90" align="right">
<template #default="{ row }">
{{ formatCent(row.tax_amount_cent) }}
</template>
</el-table-column>
<el-table-column label="订单状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 'completed' ? 'success' : 'warning'" size="small">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="结算状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.settlement_status === 'settled' ? 'success' : 'info'" size="small">
{{ row.settlement_status === 'settled' ? '已结算' : '待结算' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" min-width="160">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadOrders"
@size-change="loadOrders"
/>
</div>
</el-card>
</div>
</template>
<style scoped>
.distribution-orders-view {
padding: 20px;
}
.page-header {
margin-bottom: 20px;
}
.page-title {
font-size: 20px;
font-weight: 600;
margin: 0 0 6px 0;
color: #1f2937;
}
.page-subtitle {
font-size: 13px;
color: #6b7280;
margin: 0;
}
.filter-card {
padding-bottom: 0;
}
.pagination-bar {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import {
fetchDistributionRules,
fetchDistributionSites,
updateDistributionRuleStatus,
type DistributionRule,
type DistributionSite,
} from '@/features/admin/api/adminDistribution'
import { formatDateTime } from '@/shared/utils/time'
import { readError } from '@/shared/utils/error'
const loading = ref(false)
const sites = ref<DistributionSite[]>([])
const selectedSiteId = ref<number | undefined>(undefined)
const rules = ref<DistributionRule[]>([])
const modeMap: Record<string, string> = {
two_party_distributor_receive: '两方-分销收号',
two_party_platform_receive: '两方-分销卖出',
three_party: '三方分销',
}
async function loadSites() {
try {
sites.value = await fetchDistributionSites()
} catch (err) {
ElMessage.error(readError(err, '获取分销站点失败'))
}
}
async function loadRules() {
loading.value = true
try {
rules.value = await fetchDistributionRules(selectedSiteId.value)
} catch (err) {
ElMessage.error(readError(err, '获取分销规则列表失败'))
} finally {
loading.value = false
}
}
async function handleToggleStatus(row: DistributionRule) {
const next = row.status === 'active' ? 'disabled' : 'active'
try {
await updateDistributionRuleStatus(row.id, next)
ElMessage.success(`规则已${next === 'active' ? '启用' : '停用'}`)
await loadRules()
} catch (err) {
ElMessage.error(readError(err, '更新规则状态失败'))
}
}
function getSiteName(siteId?: number) {
if (!siteId) return '全局默认'
const site = sites.value.find(s => s.id === siteId)
return site ? `${site.site_name} (${site.site_code})` : `站点 #${siteId}`
}
onMounted(async () => {
await loadSites()
await loadRules()
})
</script>
<template>
<div class="distribution-rules-view">
<div class="page-header">
<div>
<h2 class="page-title">分销规则配置</h2>
<p class="page-subtitle">
配置与维护各分销站点的佣金分成比例、单笔服务费、支付通道手续费与税费规则。
</p>
</div>
<div class="header-filters">
<el-select
v-model="selectedSiteId"
clearable
placeholder="按分销站点筛选"
style="width: 240px"
@change="loadRules"
>
<el-option
v-for="site in sites"
:key="site.id"
:label="`${site.site_name} (${site.site_code})`"
:value="site.id"
/>
</el-select>
</div>
</div>
<el-card shadow="never">
<el-table v-loading="loading" :data="rules" stripe style="width: 100%">
<el-table-column label="适用站点" min-width="160">
<template #default="{ row }">
<el-tag :type="row.site_id ? 'primary' : 'info'" size="small">
{{ getSiteName(row.site_id) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="分销模式" prop="mode" min-width="140">
<template #default="{ row }">
{{ modeMap[row.mode] || row.mode }}
</template>
</el-table-column>
<el-table-column label="版本号" prop="version" width="80" align="center">
<template #default="{ row }"> v{{ row.version }} </template>
</el-table-column>
<el-table-column label="分销商收号分成" min-width="130" align="right">
<template #default="{ row }"> {{ row.distributor_receive_ratio }}% </template>
</el-table-column>
<el-table-column label="平台卖号分成" min-width="120" align="right">
<template #default="{ row }"> {{ row.platform_sell_ratio }}% </template>
</el-table-column>
<el-table-column label="固定服务费" min-width="110" align="right">
<template #default="{ row }"> ¥{{ row.service_fee_value }} </template>
</el-table-column>
<el-table-column label="支付手续费" min-width="110" align="right">
<template #default="{ row }"> {{ row.payment_fee_bps / 100 }}% </template>
</el-table-column>
<el-table-column label="税费率" min-width="100" align="center">
<template #default="{ row }">
<el-tag v-if="row.tax_enabled" type="warning" size="small">
{{ row.tax_rate_bps / 100 }}%
</el-tag>
<el-tag v-else type="info" size="small">免税</el-tag>
</template>
</el-table-column>
<el-table-column label="生效时间" min-width="160">
<template #default="{ row }">
{{ formatDateTime(row.effective_from) }}
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">
{{ row.status === 'active' ? '生效中' : '已停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button
link
:type="row.status === 'active' ? 'danger' : 'success'"
@click="handleToggleStatus(row)"
>
{{ row.status === 'active' ? '停用' : '启用' }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<style scoped>
.distribution-rules-view {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-title {
font-size: 20px;
font-weight: 600;
margin: 0 0 6px 0;
color: #1f2937;
}
.page-subtitle {
font-size: 13px;
color: #6b7280;
margin: 0;
}
</style>
@@ -0,0 +1,285 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import {
fetchDistributionSettlementBatches,
fetchDistributionSettlementItems,
createDistributionSettlementBatch,
settleDistributionBatch,
fetchDistributionSites,
type DistributionSettlementBatch,
type DistributionSettlementItem,
type DistributionSite,
} from '@/features/admin/api/adminDistribution'
import { formatCent } from '@/shared/utils/money'
import { formatDateTime } from '@/shared/utils/time'
import { readError } from '@/shared/utils/error'
const loading = ref(false)
const sites = ref<DistributionSite[]>([])
const selectedSiteId = ref<number>()
const batches = ref<DistributionSettlementBatch[]>([])
const createDialogVisible = ref(false)
const creating = ref(false)
const createForm = reactive({
site_id: undefined as number | undefined,
date_range: [] as string[],
})
const itemsDialogVisible = ref(false)
const itemsLoading = ref(false)
const currentBatch = ref<DistributionSettlementBatch | null>(null)
const items = ref<DistributionSettlementItem[]>([])
async function loadSites() {
try {
const list = await fetchDistributionSites()
sites.value = list
if (list.length > 0 && list[0]) {
selectedSiteId.value = list[0].id
await loadBatches()
}
} catch (err) {
ElMessage.error(readError(err, '获取分销站点失败'))
}
}
async function loadBatches() {
if (!selectedSiteId.value) return
loading.value = true
try {
batches.value = await fetchDistributionSettlementBatches(selectedSiteId.value)
} catch (err) {
ElMessage.error(readError(err, '获取结算批次失败'))
} finally {
loading.value = false
}
}
function handleOpenCreate() {
createForm.site_id = selectedSiteId.value
createForm.date_range = []
createDialogVisible.value = true
}
async function handleCreateBatch() {
const [start, end] = createForm.date_range
if (!createForm.site_id || !start || !end) {
ElMessage.warning('请选择站点与结算周期起止时间')
return
}
creating.value = true
try {
await createDistributionSettlementBatch(createForm.site_id, start, end)
ElMessage.success('结算批次生成成功')
createDialogVisible.value = false
await loadBatches()
} catch (err) {
ElMessage.error(readError(err, '生成结算批次失败'))
} finally {
creating.value = false
}
}
async function handleSettle(row: DistributionSettlementBatch) {
try {
await ElMessageBox.confirm(
`确定将结算批次 ${row.batch_no} 标记为已打款结算吗?结算总额为 ${formatCent(row.total_amount_cent)}。`,
'确认结算',
{ type: 'warning', confirmButtonText: '确认标记已结算', cancelButtonText: '取消' }
)
await settleDistributionBatch(row.id)
ElMessage.success('批次已成功标记为已结算')
await loadBatches()
} catch {
// cancelled or error handled
}
}
async function handleViewItems(row: DistributionSettlementBatch) {
currentBatch.value = row
itemsDialogVisible.value = true
itemsLoading.value = true
try {
items.value = await fetchDistributionSettlementItems(row.id)
} catch (err) {
ElMessage.error(readError(err, '获取结算明细失败'))
} finally {
itemsLoading.value = false
}
}
onMounted(() => {
loadSites()
})
</script>
<template>
<div class="distribution-settlements-view">
<div class="page-header">
<div>
<h2 class="page-title">分销结算管理</h2>
<p class="page-subtitle">
按周期汇总各合作分销站点的佣金分成批次,并执行最终结算出款确认。
</p>
</div>
<div class="header-actions">
<el-select
v-model="selectedSiteId"
placeholder="选择分销站点"
style="width: 220px"
@change="loadBatches"
>
<el-option
v-for="site in sites"
:key="site.id"
:label="`${site.site_name} (${site.site_code})`"
:value="site.id"
/>
</el-select>
<el-button type="primary" :icon="Plus" @click="handleOpenCreate"> 生成结算批次 </el-button>
</div>
</div>
<el-card shadow="never">
<el-table v-loading="loading" :data="batches" stripe style="width: 100%">
<el-table-column label="结算批次号" prop="batch_no" min-width="180" />
<el-table-column label="周期开始时间" min-width="160">
<template #default="{ row }">
{{ formatDateTime(row.period_start) }}
</template>
</el-table-column>
<el-table-column label="周期结束时间" min-width="160">
<template #default="{ row }">
{{ formatDateTime(row.period_end) }}
</template>
</el-table-column>
<el-table-column label="应结总金额" min-width="140" align="right">
<template #default="{ row }">
<strong style="color: #67c23a; font-size: 15px">
{{ formatCent(row.total_amount_cent) }}
</strong>
</template>
</el-table-column>
<el-table-column label="批次状态" width="120" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 'settled' ? 'success' : 'warning'" size="small">
{{ row.status === 'settled' ? '已打款' : '待结算' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleViewItems(row)">查看明细</el-button>
<el-button
v-if="row.status !== 'settled'"
link
type="success"
@click="handleSettle(row)"
>
打款结算
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 生成结算批次对话框 -->
<el-dialog
v-model="createDialogVisible"
title="生成分销结算批次"
width="500px"
destroy-on-close
>
<el-form label-width="100px">
<el-form-item label="分销站点">
<el-select v-model="createForm.site_id" style="width: 100%">
<el-option
v-for="site in sites"
:key="site.id"
:label="`${site.site_name} (${site.site_code})`"
:value="site.id"
/>
</el-select>
</el-form-item>
<el-form-item label="结算周期">
<el-date-picker
v-model="createForm.date_range"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="creating" @click="handleCreateBatch">
立即结算汇总
</el-button>
</template>
</el-dialog>
<!-- 结算条目明细对话框 -->
<el-dialog
v-model="itemsDialogVisible"
:title="`结算批次明细 (${currentBatch?.batch_no})`"
width="650px"
>
<el-table v-loading="itemsLoading" :data="items" size="small" border>
<el-table-column label="订单 ID" prop="order_id" width="100" />
<el-table-column label="分成角色" prop="party_role" min-width="150" />
<el-table-column label="应分金额" min-width="120" align="right">
<template #default="{ row }">
<strong>{{ formatCent(row.amount_cent) }}</strong>
</template>
</el-table-column>
<el-table-column label="状态" prop="status" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 'settled' ? 'success' : 'info'" size="small">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="itemsDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.distribution-settlements-view {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-title {
font-size: 20px;
font-weight: 600;
margin: 0 0 6px 0;
color: #1f2937;
}
.page-subtitle {
font-size: 13px;
color: #6b7280;
margin: 0;
}
.header-actions {
display: flex;
gap: 12px;
}
</style>
@@ -0,0 +1,406 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { Plus, Shop, Link, DocumentCopy } from '@element-plus/icons-vue'
import {
fetchDistributionSites,
onboardDistributionSite,
type DistributionSite,
type OnboardDistributionSitePayload,
type OnboardDistributionSiteResult,
} from '@/features/admin/api/adminDistribution'
import { formatDateTime } from '@/shared/utils/time'
import { readError } from '@/shared/utils/error'
const loading = ref(false)
const sites = ref<DistributionSite[]>([])
const onboardDialogVisible = ref(false)
const submitting = ref(false)
const onboardFormRef = ref<FormInstance>()
const successDialogVisible = ref(false)
const onboardResult = ref<OnboardDistributionSiteResult | null>(null)
const onboardForm = reactive({
site_name: '',
site_code: '',
domain: '',
admin_username: '',
admin_password: '',
admin_nickname: '',
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,
})
const onboardRules: FormRules = {
site_name: [{ required: true, message: '请输入分销站点名称', trigger: 'blur' }],
site_code: [
{ required: true, message: '请输入站点唯一标识', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9_-]+$/, message: '仅支持英文字母、数字及下划线', trigger: 'blur' },
],
domain: [{ required: true, message: '请输入分销专属域名', trigger: 'blur' }],
admin_username: [{ required: true, message: '请输入分销管理员账号', trigger: 'blur' }],
admin_password: [
{ required: true, message: '请输入初始密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' },
],
}
async function loadSites() {
loading.value = true
try {
sites.value = await fetchDistributionSites()
} catch (err) {
ElMessage.error(readError(err, '获取分销站点列表失败'))
} finally {
loading.value = false
}
}
function handleOpenOnboard() {
onboardForm.site_name = ''
onboardForm.site_code = ''
onboardForm.domain = ''
onboardForm.admin_username = ''
onboardForm.admin_password = ''
onboardForm.admin_nickname = ''
onboardForm.member_type = 'operator'
onboardForm.distributor_receive_ratio = 3.5
onboardForm.platform_sell_ratio = 4.0
onboardForm.service_fee_value = 2.5
onboardForm.display_account_enabled = true
onboardForm.tax_enabled = false
onboardForm.tax_rate_bps = 100
onboardDialogVisible.value = true
}
async function handleOnboardSubmit() {
if (!onboardFormRef.value) return
await onboardFormRef.value.validate(async valid => {
if (!valid) return
submitting.value = true
try {
const payload: OnboardDistributionSitePayload = {
site_name: onboardForm.site_name.trim(),
site_code: onboardForm.site_code.trim(),
domain: onboardForm.domain.trim(),
admin_username: onboardForm.admin_username.trim(),
admin_password: onboardForm.admin_password,
admin_nickname: onboardForm.admin_nickname.trim() || undefined,
member_type: onboardForm.member_type,
distributor_receive_ratio: Number(onboardForm.distributor_receive_ratio),
platform_sell_ratio: Number(onboardForm.platform_sell_ratio),
service_fee_value: Number(onboardForm.service_fee_value),
display_account_enabled: onboardForm.display_account_enabled,
tax_enabled: onboardForm.tax_enabled,
tax_rate_bps: onboardForm.tax_enabled ? onboardForm.tax_rate_bps : 0,
}
const res = await onboardDistributionSite(payload)
ElMessage.success('分销站点与管理员账号已成功开通!')
onboardDialogVisible.value = false
onboardResult.value = res
successDialogVisible.value = true
await loadSites()
} catch (err) {
ElMessage.error(readError(err, '开通分销商失败'))
} finally {
submitting.value = false
}
})
}
function handleCopyInfo() {
if (!onboardResult.value) return
const text = `分销站点:${onboardResult.value.site.site_name} (${onboardResult.value.site.site_code})\n分销域名:${onboardResult.value.site.domain}\n后台登录地址:http://${window.location.hostname}:5190/login\n管理员账号:${onboardResult.value.member.username}`
navigator.clipboard?.writeText(text)
ElMessage.success('已复制分销商开通凭证到剪贴板')
}
function openDistributionAdmin() {
window.open(`http://${window.location.hostname}:5190`, '_blank')
}
onMounted(() => {
loadSites()
})
</script>
<template>
<div class="distribution-sites-view">
<div class="page-header">
<div>
<h2 class="page-title">分销站点管理</h2>
<p class="page-subtitle">
统一管理全平台合作分销站点,支持一键开通分销商、初始化管理员账号及专属佣金费率。
</p>
</div>
<div class="header-actions">
<el-button :icon="Link" @click="openDistributionAdmin"> 打开独立分销后台 </el-button>
<el-button type="primary" :icon="Plus" @click="handleOpenOnboard">
一键开通分销商
</el-button>
</div>
</div>
<el-card shadow="never">
<el-table v-loading="loading" :data="sites" stripe style="width: 100%">
<el-table-column label="站点名称" prop="site_name" min-width="160">
<template #default="{ row }">
<div class="site-name-cell">
<el-icon class="site-icon"><Shop /></el-icon>
<strong>{{ row.site_name }}</strong>
</div>
</template>
</el-table-column>
<el-table-column label="站点标识" prop="site_code" min-width="120">
<template #default="{ row }">
<el-tag size="small">{{ row.site_code }}</el-tag>
</template>
</el-table-column>
<el-table-column label="绑定域名" prop="domain" min-width="180" />
<el-table-column label="运行状态" prop="status" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small">
{{ row.status === 'active' ? '正常' : '已停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="展示主站账号" prop="display_account_enabled" width="130">
<template #default="{ row }">
<el-tag :type="row.display_account_enabled ? 'success' : 'info'" size="small">
{{ row.display_account_enabled ? '已开启' : '关闭' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="税费设定" width="120">
<template #default="{ row }">
<el-tag v-if="row.tax_enabled" type="warning" size="small">
{{ (row.tax_rate_bps / 100).toFixed(1) }}%
</el-tag>
<el-tag v-else type="info" size="small">免税</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" prop="created_at" min-width="160">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 一键开通分销商对话框 -->
<el-dialog v-model="onboardDialogVisible" title="一键开通分销商" width="680px" destroy-on-close>
<el-form ref="onboardFormRef" :model="onboardForm" :rules="onboardRules" label-width="140px">
<div class="form-section-title">1. 分销站点基础信息</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="站点名称" prop="site_name">
<el-input v-model="onboardForm.site_name" placeholder="例如:广州游戏分销站" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="站点唯一标识" prop="site_code">
<el-input v-model="onboardForm.site_code" placeholder="例如:gz_dist" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="分销专属域名" prop="domain">
<el-input v-model="onboardForm.domain" placeholder="例如:gz.distribution.hao858.com" />
</el-form-item>
<div class="form-section-title">2. 分销商管理员账号</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="登录用户名" prop="admin_username">
<el-input v-model="onboardForm.admin_username" placeholder="例如:gz_admin" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="初始登录密码" prop="admin_password">
<el-input
v-model="onboardForm.admin_password"
type="password"
show-password
placeholder="至少6位"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="站长姓名/昵称" prop="admin_nickname">
<el-input v-model="onboardForm.admin_nickname" placeholder="例如:张站长" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="初始角色权限" prop="member_type">
<el-select v-model="onboardForm.member_type" style="width: 100%">
<el-option label="分销运营 (operator)" value="operator" />
<el-option label="分销财务 (finance)" value="finance" />
<el-option label="客服人员 (customer_service)" value="customer_service" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<div class="form-section-title">3. 佣金比例与费率初始化</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="收号分成比例 (%)">
<el-input-number
v-model="onboardForm.distributor_receive_ratio"
:min="0"
:max="100"
:step="0.1"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="平台卖号分成 (%)">
<el-input-number
v-model="onboardForm.platform_sell_ratio"
:min="0"
:max="100"
:step="0.1"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="单笔服务费 (元)">
<el-input-number
v-model="onboardForm.service_fee_value"
:min="0"
:step="0.5"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="展示主站账号">
<el-switch v-model="onboardForm.display_account_enabled" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="是否征税">
<el-switch v-model="onboardForm.tax_enabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="onboardDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleOnboardSubmit">
立即开通并生成分销商
</el-button>
</template>
</el-dialog>
<!-- 开通成功凭证对话框 -->
<el-dialog v-model="successDialogVisible" title="分销商开通成功" width="560px">
<div v-if="onboardResult">
<el-alert
type="success"
:closable="false"
show-icon
title="分销站点与管理员账号已成功初始化,请妥善保管交付凭证:"
style="margin-bottom: 16px"
/>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="分销站点">
<strong>{{ onboardResult.site.site_name }}</strong> ({{ onboardResult.site.site_code }})
</el-descriptions-item>
<el-descriptions-item label="分销专属域名">
{{ onboardResult.site.domain }}
</el-descriptions-item>
<el-descriptions-item label="分销后台登录账号">
<el-tag type="success">{{ onboardResult.member.username }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="分销后台地址">
<code>http://{{ windowHost }}:5190/login</code>
</el-descriptions-item>
<el-descriptions-item label="初始规则">
收号分成: {{ onboardResult.rule.distributor_receive_ratio }}% · 卖号分成:
{{ onboardResult.rule.platform_sell_ratio }}% · 服务费: ¥{{
onboardResult.rule.service_fee_value
}}
</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<el-button :icon="DocumentCopy" type="primary" @click="handleCopyInfo">
一键复制交付信息
</el-button>
<el-button @click="successDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script lang="ts">
export default {
data() {
return {
windowHost: typeof window !== 'undefined' ? window.location.hostname : 'localhost',
}
},
}
</script>
<style scoped>
.distribution-sites-view {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-title {
font-size: 20px;
font-weight: 600;
margin: 0 0 6px 0;
color: #1f2937;
}
.page-subtitle {
font-size: 13px;
color: #6b7280;
margin: 0;
}
.header-actions {
display: flex;
gap: 12px;
}
.site-name-cell {
display: flex;
align-items: center;
gap: 8px;
}
.site-icon {
color: #409eff;
font-size: 16px;
}
.form-section-title {
font-size: 14px;
font-weight: 600;
color: #374151;
margin: 16px 0 12px 0;
padding-bottom: 6px;
border-bottom: 1px dashed #e5e7eb;
}
</style>
@@ -164,7 +164,6 @@ function userText(value: string | number | undefined) {
function isPlatformManaged(row: AdminOrderListItem) {
return row.handoff_mode === 'platform' || row.settlement_mode === 'platform_managed'
}
</script>
<template>
@@ -1,7 +1,7 @@
import { onBeforeUnmount, ref, type Ref } from 'vue'
import axios from 'axios'
import { redirectToLogin, refreshAccessToken } from '@/shared/api/client'
import { getAccessToken, type AuthScope } from '@/shared/utils/authStorage'
import { type AuthScope } from '@/shared/utils/authStorage'
export interface SSEMessage {
id: number
+32
View File
@@ -28,6 +28,7 @@ import {
UserFilled,
Wallet,
CreditCard,
Share,
} from '@element-plus/icons-vue'
import { ElMessage, ElSubMenu } from 'element-plus'
import type { Component } from 'vue'
@@ -131,6 +132,37 @@ const allNavGroups: NavGroup[] = [
},
],
},
{
label: '分销管理',
index: 'distribution',
icon: Share,
children: [
{
label: '分销站点',
to: adminPath('distribution/sites'),
icon: Shop,
permission: 'distribution:site_view',
},
{
label: '分销规则',
to: adminPath('distribution/rules'),
icon: Setting,
permission: 'distribution:rule_view',
},
{
label: '分销订单',
to: adminPath('distribution/orders'),
icon: Tickets,
permission: 'distribution:order_view',
},
{
label: '分销结算',
to: adminPath('distribution/settlements'),
icon: Coin,
permission: 'distribution:settlement_view',
},
],
},
{
label: '客服与风控',
index: 'support-risk',
+24
View File
@@ -216,4 +216,28 @@ export const adminRoutes: RouteRecordRaw[] = [
component: () => import('@/features/admin/views/AdminAnnouncementsView.vue'),
meta: adminMeta,
},
{
path: adminPath('distribution/sites'),
name: 'admin-distribution-sites',
component: () => import('@/features/admin/views/AdminDistributionSitesView.vue'),
meta: adminMeta,
},
{
path: adminPath('distribution/rules'),
name: 'admin-distribution-rules',
component: () => import('@/features/admin/views/AdminDistributionRulesView.vue'),
meta: adminMeta,
},
{
path: adminPath('distribution/orders'),
name: 'admin-distribution-orders',
component: () => import('@/features/admin/views/AdminDistributionOrdersView.vue'),
meta: adminMeta,
},
{
path: adminPath('distribution/settlements'),
name: 'admin-distribution-settlements',
component: () => import('@/features/admin/views/AdminDistributionSettlementsView.vue'),
meta: adminMeta,
},
]
+124 -31
View File
@@ -10,16 +10,23 @@ FRONTEND_PACKAGE_JSON="${FRONTEND_DIR}/package.json"
FRONTEND_PACKAGE_LOCK="${FRONTEND_DIR}/package-lock.json"
FRONTEND_NODE_MODULES="${FRONTEND_DIR}/node_modules"
FRONTEND_NODE_MODULES_LOCK="${FRONTEND_NODE_MODULES}/.package-lock.json"
DISTRIBUTION_DIR="${ROOT_DIR}/distribution-admin"
DISTRIBUTION_PACKAGE_JSON="${DISTRIBUTION_DIR}/package.json"
DISTRIBUTION_PACKAGE_LOCK="${DISTRIBUTION_DIR}/package-lock.json"
DISTRIBUTION_NODE_MODULES="${DISTRIBUTION_DIR}/node_modules"
DISTRIBUTION_NODE_MODULES_LOCK="${DISTRIBUTION_NODE_MODULES}/.package-lock.json"
MIGRATIONS_DIR="${ROOT_DIR}/backend/migrations"
MYSQL_DATABASE="${DEV_MYSQL_DATABASE:-hfb_sys}"
BACKEND_PORT="${DEV_BACKEND_PORT:-18088}"
FRONTEND_PORT="${DEV_FRONTEND_PORT:-5189}"
DISTRIBUTION_PORT="${DEV_DISTRIBUTION_PORT:-5190}"
MYSQL_PORT="${DEV_MYSQL_PORT:-13306}"
REDIS_PORT="${DEV_REDIS_PORT:-16379}"
MINIO_API_PORT="${DEV_MINIO_API_PORT:-19090}"
MINIO_CONSOLE_PORT="${DEV_MINIO_CONSOLE_PORT:-19091}"
BACKEND_HEALTH_URL="${DEV_BACKEND_HEALTH_URL:-http://127.0.0.1:${BACKEND_PORT}/health}"
FRONTEND_HEALTH_URL="${DEV_FRONTEND_HEALTH_URL:-http://127.0.0.1:${FRONTEND_PORT}/}"
DISTRIBUTION_HEALTH_URL="${DEV_DISTRIBUTION_HEALTH_URL:-http://127.0.0.1:${DISTRIBUTION_PORT}/}"
HTTP_READY_TIMEOUT="${DEV_HTTP_READY_TIMEOUT:-120}"
AUTO_KILL_PORTS="${DEV_AUTO_KILL_PORTS:-1}"
FORCE_NPM_CI="${DEV_FORCE_NPM_CI:-0}"
@@ -28,6 +35,7 @@ GOOSE_BIN="${GOOSE_BIN:-}"
BACKEND_PID=""
FRONTEND_PID=""
DISTRIBUTION_PID=""
CLEANED_UP=0
# 颜色定义
@@ -65,41 +73,46 @@ show_help() {
用法: ./scripts/dev.sh [选项]
选项:
--reset-db 重置数据库(删除并重建)
--no-migrate 不执行数据库迁移
--no-frontend 不启动前端
--no-backend 不启动后端
-h, --help 显示帮助信息
--reset-db 重置数据库(删除并重建)
--no-migrate 不执行数据库迁移
--no-frontend 不启动主站前端
--no-backend 不启动后端
--no-distribution 不启动分销后台
-h, --help 显示帮助信息
环境变量:
DEV_RESET_DB=1 等同于 --reset-db
DEV_RESET_DB=1 等同于 --reset-db
DEV_MYSQL_DATABASE=hfb_sys
开发数据库名
开发数据库名
DEV_BACKEND_PORT=18088
后端启动端口
后端启动端口
DEV_FRONTEND_PORT=5189
前端启动端口
主站前端启动端口
DEV_DISTRIBUTION_PORT=5190
分销后台启动端口
DEV_MYSQL_PORT=13306
MySQL 宿主机映射端口
MySQL 宿主机映射端口
DEV_REDIS_PORT=16379
Redis 宿主机映射端口
Redis 宿主机映射端口
DEV_MINIO_API_PORT=19090
MinIO API 宿主机映射端口
MinIO API 宿主机映射端口
DEV_MINIO_CONSOLE_PORT=19091
MinIO 控制台宿主机映射端口
MinIO 控制台宿主机映射端口
DEV_BACKEND_HEALTH_URL=http://127.0.0.1:18088/health
后端健康检查地址
后端健康检查地址
DEV_FRONTEND_HEALTH_URL=http://127.0.0.1:5189/
前端健康检查地址
主站前端健康检查地址
DEV_DISTRIBUTION_HEALTH_URL=http://127.0.0.1:5190/
分销后台健康检查地址
DEV_HTTP_READY_TIMEOUT=120
HTTP 服务就绪等待秒数
HTTP 服务就绪等待秒数
DEV_AUTO_KILL_PORTS=1
前后端端口被占用时自动停止占用的 Docker 容器或本机进程
前后端端口被占用时自动停止占用的 Docker 容器或本机进程
DEV_FORCE_NPM_CI=0
强制重新执行前端 npm ci
强制重新执行前端 npm ci
示例:
./scripts/dev.sh # 正常启动
./scripts/dev.sh # 正常启动(后端 + 主前后台 + 分销前台)
./scripts/dev.sh --reset-db # 重置数据库后启动(推荐迁移后使用)
EOF
exit 0
@@ -110,6 +123,7 @@ RESET_DB="${DEV_RESET_DB:-0}"
NO_MIGRATE=0
NO_FRONTEND=0
NO_BACKEND=0
NO_DISTRIBUTION=0
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -129,6 +143,10 @@ while [[ $# -gt 0 ]]; do
NO_BACKEND=1
shift
;;
--no-distribution)
NO_DISTRIBUTION=1
shift
;;
-h|--help)
show_help
;;
@@ -145,11 +163,14 @@ cleanup() {
fi
CLEANED_UP=1
if [[ -z "${FRONTEND_PID}" && -z "${BACKEND_PID}" ]]; then
if [[ -z "${FRONTEND_PID}" && -z "${BACKEND_PID}" && -z "${DISTRIBUTION_PID}" ]]; then
return
fi
log "正在停止前后端开发进程..."
if [[ -n "${DISTRIBUTION_PID}" ]] && kill -0 "${DISTRIBUTION_PID}" >/dev/null 2>&1; then
kill "${DISTRIBUTION_PID}" >/dev/null 2>&1 || true
fi
if [[ -n "${FRONTEND_PID}" ]] && kill -0 "${FRONTEND_PID}" >/dev/null 2>&1; then
kill "${FRONTEND_PID}" >/dev/null 2>&1 || true
fi
@@ -540,6 +561,64 @@ start_frontend() {
FRONTEND_PID="$!"
}
distribution_dependencies_need_ci() {
if [[ "${FORCE_NPM_CI}" == "1" ]]; then
return 0
fi
if [[ ! -d "${DISTRIBUTION_NODE_MODULES}" ]]; then
return 0
fi
if [[ ! -f "${DISTRIBUTION_NODE_MODULES_LOCK}" ]]; then
return 0
fi
if [[ "${DISTRIBUTION_PACKAGE_JSON}" -nt "${DISTRIBUTION_NODE_MODULES_LOCK}" || "${DISTRIBUTION_PACKAGE_LOCK}" -nt "${DISTRIBUTION_NODE_MODULES_LOCK}" ]]; then
return 0
fi
(
cd "${DISTRIBUTION_DIR}"
npm ls --depth=0 >/dev/null 2>&1
) || return 0
return 1
}
ensure_distribution_dependencies() {
if [[ ! -f "${DISTRIBUTION_PACKAGE_LOCK}" ]]; then
log_error "缺少 distribution-admin/package-lock.json,无法执行 npm ci"
exit 1
fi
if distribution_dependencies_need_ci; then
log "同步分销后台依赖(npm ci)..."
(
cd "${DISTRIBUTION_DIR}"
npm ci
)
log_success "分销后台依赖已同步"
else
log "分销后台依赖已同步,跳过 npm ci"
fi
}
start_distribution() {
ensure_distribution_dependencies
log "启动分销后台:http://localhost:${DISTRIBUTION_PORT}"
(
cd "${DISTRIBUTION_DIR}"
export PORT="${DISTRIBUTION_PORT}"
export DEV_DISTRIBUTION_PORT="${DISTRIBUTION_PORT}"
export DEV_BACKEND_PORT="${BACKEND_PORT}"
export VITE_API_PROXY_TARGET="${VITE_API_PROXY_TARGET:-http://127.0.0.1:${BACKEND_PORT}}"
npm run dev
) &
DISTRIBUTION_PID="$!"
}
watch_processes() {
while true; do
if [[ -n "${BACKEND_PID}" ]] && ! kill -0 "${BACKEND_PID}" >/dev/null 2>&1; then
@@ -549,7 +628,12 @@ watch_processes() {
fi
if [[ -n "${FRONTEND_PID}" ]] && ! kill -0 "${FRONTEND_PID}" >/dev/null 2>&1; then
wait "${FRONTEND_PID}" || true
printf "前端进程已退出\n" >&2
printf "主站前端进程已退出\n" >&2
exit 1
fi
if [[ -n "${DISTRIBUTION_PID}" ]] && ! kill -0 "${DISTRIBUTION_PID}" >/dev/null 2>&1; then
wait "${DISTRIBUTION_PID}" || true
printf "分销后台进程已退出\n" >&2
exit 1
fi
sleep 2
@@ -561,10 +645,10 @@ main() {
if [[ "${NO_BACKEND}" == "0" ]]; then
need_cmd go
fi
if [[ "${NO_FRONTEND}" == "0" ]]; then
if [[ "${NO_FRONTEND}" == "0" || "${NO_DISTRIBUTION}" == "0" ]]; then
need_cmd npm
fi
if [[ "${NO_BACKEND}" == "0" || "${NO_FRONTEND}" == "0" ]]; then
if [[ "${NO_BACKEND}" == "0" || "${NO_FRONTEND}" == "0" || "${NO_DISTRIBUTION}" == "0" ]]; then
need_cmd curl
fi
@@ -603,24 +687,33 @@ main() {
fi
if [[ "${NO_FRONTEND}" == "0" ]]; then
ensure_port_free "${FRONTEND_PORT}" "前端"
ensure_port_free "${FRONTEND_PORT}" "主站前端"
start_frontend
wait_http_ready "${FRONTEND_HEALTH_URL}" "前端" "${FRONTEND_PID}"
wait_http_ready "${FRONTEND_HEALTH_URL}" "主站前端" "${FRONTEND_PID}"
fi
if [[ -z "${BACKEND_PID}" && -z "${FRONTEND_PID}" ]]; then
log "仅启动 Docker 开发依赖,前后端未启动"
if [[ "${NO_DISTRIBUTION}" == "0" && -d "${DISTRIBUTION_DIR}" ]]; then
ensure_port_free "${DISTRIBUTION_PORT}" "分销后台"
start_distribution
wait_http_ready "${DISTRIBUTION_HEALTH_URL}" "分销后台" "${DISTRIBUTION_PID}"
fi
if [[ -z "${BACKEND_PID}" && -z "${FRONTEND_PID}" && -z "${DISTRIBUTION_PID}" ]]; then
log "仅启动 Docker 开发依赖,前后端与分销端未启动"
return 0
fi
log_success "开发环境已启动"
log_success "开发环境已就绪"
if [[ "${NO_FRONTEND}" == "0" ]]; then
local admin_base_path="${VITE_ADMIN_BASE_PATH:-/admin}"
log "前台:http://localhost:${FRONTEND_PORT}"
log "后台:http://localhost:${FRONTEND_PORT}${admin_base_path%/}/login"
log "前台地址:http://localhost:${FRONTEND_PORT}"
log "后台地址:http://localhost:${FRONTEND_PORT}${admin_base_path%/}/login"
fi
if [[ "${NO_DISTRIBUTION}" == "0" && -d "${DISTRIBUTION_DIR}" ]]; then
log "分销后台:http://localhost:${DISTRIBUTION_PORT}"
fi
log "按 Ctrl+C 停止前后端;Docker 依赖会保留运行"
log "按 Ctrl+C 停止所有服务;Docker 依赖会保留运行"
watch_processes
}