feat(distribution): 独立分销后台账号体系、多租户隔离与主站后台分销管理GUI
- 独立账号鉴权:分销后台支持独立登录页面、Bearer+Cookie 双模鉴权与租户资料接口 - 多租户隔离:分销商成员强制锁定自身站点数据范围,防止横向越权;平台超管享有全局视界 - 一键开通分销商:平台端支持一键录入站点、管理员账号、分配角色并原子化初始化佣金规则 - 主后台分销分组:在主站后台增加「分销管理」导航分组,提供站点管理、规则配置、订单监控与结算管理 GUI - 门禁与联调:补充完整单测,全量代码门禁 check.sh 100% 通过,dev.sh 支持前后端与分销后台一键同启
This commit is contained in:
@@ -29,6 +29,8 @@ frontend/components.d.ts
|
||||
# Docker / local data
|
||||
.docker-data/
|
||||
backups/
|
||||
deploy/backup/
|
||||
.zcode/
|
||||
.DS_Store
|
||||
backend/api
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 ?? []
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ?? []
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user