feat(distribution): 分销站点管理支持完整 CRUD、安全删除保护与管理员密码重置
- 后端支持分销站点编辑(域名唯一性校验、收号压价点位及税率更新) - 后端支持分销站点安全删除校验(阻断存在业务订单、财务流水、结算批次或钱包余额的站点删除;对干净站点连带清理分成规则、展示账号绑定、支付通道配置及专有站长账号) - 后端支持站长账号展示与超级管理员重置站长密码 - 主管理后台与分销后台前端全面支持筛选搜索、编辑模态窗、状态快捷切换、站长密码重置与安全删除确认 - 补充后端 CRUD、删除保护与密码重置覆盖率测试用例
This commit is contained in:
@@ -21,10 +21,18 @@ type SiteDTO struct {
|
||||
TaxEnabled bool `json:"tax_enabled"`
|
||||
TaxRateBPS int64 `json:"tax_rate_bps"`
|
||||
SettlementHoldDays int `json:"settlement_hold_days"`
|
||||
AdminUserID *uint64 `json:"admin_user_id,omitempty"`
|
||||
AdminUsername string `json:"admin_username,omitempty"`
|
||||
AdminNickname string `json:"admin_nickname,omitempty"`
|
||||
MemberType string `json:"member_type,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ResetSiteAdminPasswordRequest struct {
|
||||
NewPassword string `json:"new_password" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
type CreateSiteRequest struct {
|
||||
SiteCode string `json:"site_code" binding:"required,max=64"`
|
||||
SiteName string `json:"site_name" binding:"required,max=128"`
|
||||
|
||||
@@ -83,6 +83,37 @@ func (h *Handler) UpdateSite(c *gin.Context) {
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteSite(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
adminID, _ := currentAdminID(c)
|
||||
if err := h.service.DeleteSite(c.Request.Context(), adminID, id); err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
func (h *Handler) ResetSiteAdminPassword(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req ResetSiteAdminPasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "密码格式不正确(至少6位)")
|
||||
return
|
||||
}
|
||||
adminID, _ := currentAdminID(c)
|
||||
if err := h.service.ResetSiteAdminPassword(c.Request.Context(), adminID, id, req.NewPassword); err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"message": "分销商密码重置成功"})
|
||||
}
|
||||
|
||||
func (h *Handler) GetSiteSMSConfig(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
|
||||
@@ -281,9 +281,46 @@ func (r *Repository) ListSites(ctx context.Context) ([]SiteDTO, error) {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]SiteDTO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if len(rows) == 0 {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
siteIDs := make([]uint64, len(rows))
|
||||
for i, row := range rows {
|
||||
siteIDs[i] = row.ID
|
||||
items = append(items, siteDTO(row))
|
||||
}
|
||||
|
||||
type memberRow struct {
|
||||
SiteID uint64 `gorm:"column:site_id"`
|
||||
AdminUserID uint64 `gorm:"column:admin_user_id"`
|
||||
Username string `gorm:"column:username"`
|
||||
Nickname string `gorm:"column:nickname"`
|
||||
MemberType string `gorm:"column:member_type"`
|
||||
}
|
||||
var memberRows []memberRow
|
||||
_ = r.db.WithContext(ctx).Table("distribution_site_members").
|
||||
Select("distribution_site_members.site_id, distribution_site_members.admin_user_id, admin_users.username, admin_users.nickname, distribution_site_members.member_type").
|
||||
Joins("JOIN admin_users ON admin_users.id = distribution_site_members.admin_user_id").
|
||||
Where("distribution_site_members.site_id IN ?", siteIDs).
|
||||
Scan(&memberRows).Error
|
||||
|
||||
memberMap := make(map[uint64]memberRow, len(memberRows))
|
||||
for _, m := range memberRows {
|
||||
if _, exists := memberMap[m.SiteID]; !exists {
|
||||
memberMap[m.SiteID] = m
|
||||
}
|
||||
}
|
||||
|
||||
for i := range items {
|
||||
if m, ok := memberMap[items[i].ID]; ok {
|
||||
items[i].AdminUserID = &m.AdminUserID
|
||||
items[i].AdminUsername = m.Username
|
||||
items[i].AdminNickname = m.Nickname
|
||||
items[i].MemberType = m.MemberType
|
||||
}
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
@@ -299,6 +336,24 @@ func (r *Repository) FindSite(ctx context.Context, adminID, id uint64) (*SiteDTO
|
||||
return nil, err
|
||||
}
|
||||
item := siteDTO(row)
|
||||
|
||||
var m struct {
|
||||
AdminUserID uint64 `gorm:"column:admin_user_id"`
|
||||
Username string `gorm:"column:username"`
|
||||
Nickname string `gorm:"column:nickname"`
|
||||
MemberType string `gorm:"column:member_type"`
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Table("distribution_site_members").
|
||||
Select("distribution_site_members.admin_user_id, admin_users.username, admin_users.nickname, distribution_site_members.member_type").
|
||||
Joins("JOIN admin_users ON admin_users.id = distribution_site_members.admin_user_id").
|
||||
Where("distribution_site_members.site_id = ?", row.ID).
|
||||
First(&m).Error; err == nil {
|
||||
item.AdminUserID = &m.AdminUserID
|
||||
item.AdminUsername = m.Username
|
||||
item.AdminNickname = m.Nickname
|
||||
item.MemberType = m.MemberType
|
||||
}
|
||||
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
@@ -416,10 +471,23 @@ func (r *Repository) UpdateSite(ctx context.Context, adminID, id uint64, req Upd
|
||||
row.SiteName = strings.TrimSpace(*req.SiteName)
|
||||
}
|
||||
if req.Domain != nil {
|
||||
row.Domain = strings.TrimSpace(*req.Domain)
|
||||
newDomain := strings.TrimSpace(*req.Domain)
|
||||
if newDomain != "" && newDomain != row.Domain {
|
||||
var count int64
|
||||
if err := tx.Model(&model.DistributionSite{}).Where("domain = ? AND id != ?", newDomain, row.ID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return errors.New("专属域名已被其他分销站点占用")
|
||||
}
|
||||
row.Domain = newDomain
|
||||
}
|
||||
}
|
||||
if req.Status != nil {
|
||||
row.Status = strings.TrimSpace(*req.Status)
|
||||
status := strings.TrimSpace(*req.Status)
|
||||
if status == "active" || status == "disabled" {
|
||||
row.Status = status
|
||||
}
|
||||
}
|
||||
if req.DisplayAccountEnabled != nil {
|
||||
row.DisplayAccountEnabled = *req.DisplayAccountEnabled
|
||||
@@ -473,9 +541,211 @@ func (r *Repository) UpdateSite(ctx context.Context, adminID, id uint64, req Upd
|
||||
}
|
||||
r.evictSiteInfoCache(ctx, &row)
|
||||
item := siteDTO(row)
|
||||
|
||||
var m struct {
|
||||
AdminUserID uint64 `gorm:"column:admin_user_id"`
|
||||
Username string `gorm:"column:username"`
|
||||
Nickname string `gorm:"column:nickname"`
|
||||
MemberType string `gorm:"column:member_type"`
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Table("distribution_site_members").
|
||||
Select("distribution_site_members.admin_user_id, admin_users.username, admin_users.nickname, distribution_site_members.member_type").
|
||||
Joins("JOIN admin_users ON admin_users.id = distribution_site_members.admin_user_id").
|
||||
Where("distribution_site_members.site_id = ?", row.ID).
|
||||
First(&m).Error; err == nil {
|
||||
item.AdminUserID = &m.AdminUserID
|
||||
item.AdminUsername = m.Username
|
||||
item.AdminNickname = m.Nickname
|
||||
item.MemberType = m.MemberType
|
||||
}
|
||||
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
// DeleteSite 删除分销站点。
|
||||
// 若分销站点已产生业务订单、财务流水或结算记录,则拒绝物理删除,保护数据与审计链路;
|
||||
// 对于未产生业务的测试站点,将级联清理成员关联、专属分销商账号、规则快照、上架关系与钱包账户。
|
||||
func (r *Repository) DeleteSite(ctx context.Context, adminID, siteID uint64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
global, err := r.hasGlobalDistributionAccess(ctx, adminID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !global && adminID != 0 {
|
||||
return ErrSiteAccessDenied
|
||||
}
|
||||
|
||||
var site model.DistributionSite
|
||||
if err := r.db.WithContext(ctx).First(&site, siteID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrSiteNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 1. 检查是否存在订单
|
||||
var orderCount int64
|
||||
if err := r.db.WithContext(ctx).Model(&model.RentalOrder{}).
|
||||
Where("distribution_site_id = ? OR markup_site_id = ?", siteID, siteID).
|
||||
Count(&orderCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if orderCount > 0 {
|
||||
return fmt.Errorf("分销站点【%s】已产生 %d 笔业务订单,为保障财务数据完整性无法删除;请将站点状态修改为「已停用」", site.SiteName, orderCount)
|
||||
}
|
||||
|
||||
// 2. 检查是否存在财务快照流水
|
||||
var financialCount int64
|
||||
if err := r.db.WithContext(ctx).Model(&model.DistributionOrderFinancial{}).
|
||||
Where("site_id = ?", siteID).
|
||||
Count(&financialCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if financialCount > 0 {
|
||||
return fmt.Errorf("分销站点【%s】已存在分账财务流水,无法直接删除;请将站点状态修改为「已停用」", site.SiteName)
|
||||
}
|
||||
|
||||
// 3. 检查是否存在结算批次
|
||||
var settlementCount int64
|
||||
if err := r.db.WithContext(ctx).Model(&model.DistributionSettlementBatch{}).
|
||||
Where("site_id = ?", siteID).
|
||||
Count(&settlementCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if settlementCount > 0 {
|
||||
return fmt.Errorf("分销站点【%s】已存在结算批次记录,无法直接删除", site.SiteName)
|
||||
}
|
||||
|
||||
// 4. 检查钱包账户余额
|
||||
var walletAccounts []model.WalletAccount
|
||||
if err := r.db.WithContext(ctx).Where("distribution_site_id = ?", siteID).Find(&walletAccounts).Error; err == nil {
|
||||
for _, wa := range walletAccounts {
|
||||
if wa.AvailableBalanceCent > 0 || wa.FrozenBalanceCent > 0 {
|
||||
return fmt.Errorf("分销站点【%s】钱包尚有未结算/未提现余额,无法删除", site.SiteName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 执行事务级联清理
|
||||
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 查询关联管理员
|
||||
var members []model.DistributionSiteMember
|
||||
if err := tx.Where("site_id = ?", siteID).Find(&members).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 清理成员关系
|
||||
if err := tx.Where("site_id = ?", siteID).Delete(&model.DistributionSiteMember{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 若管理员用户仅绑定此分销站点且非系统超管,清理其账号与角色,便于用户名复用
|
||||
for _, m := range members {
|
||||
var otherMemberCount int64
|
||||
_ = tx.Model(&model.DistributionSiteMember{}).Where("admin_user_id = ?", m.AdminUserID).Count(&otherMemberCount).Error
|
||||
if otherMemberCount == 0 {
|
||||
var adminUser model.AdminUser
|
||||
if err := tx.First(&adminUser, m.AdminUserID).Error; err == nil {
|
||||
var superRoleCount int64
|
||||
_ = tx.Table("admin_user_roles").
|
||||
Joins("JOIN roles ON roles.id = admin_user_roles.role_id").
|
||||
Where("admin_user_roles.admin_user_id = ? AND roles.code IN ('super_admin', 'admin')", m.AdminUserID).
|
||||
Count(&superRoleCount).Error
|
||||
if superRoleCount == 0 {
|
||||
_ = tx.Table("admin_user_roles").Where("admin_user_id = ?", m.AdminUserID).Delete(nil).Error
|
||||
_ = tx.Delete(&adminUser).Error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清理规则版本
|
||||
if err := tx.Where("site_id = ?", siteID).Delete(&model.DistributionRuleVersion{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 清理上架货架
|
||||
if err := tx.Where("site_id = ?", siteID).Delete(&model.DistributionSiteAccount{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 清理支付通道绑定
|
||||
if err := tx.Where("site_id = ?", siteID).Delete(&model.DistributionSitePaymentChannel{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 清理分销钱包与钱包账户
|
||||
_ = tx.Table("distribution_site_wallets").Where("site_id = ?", siteID).Delete(nil).Error
|
||||
_ = tx.Where("distribution_site_id = ?", siteID).Delete(&model.WalletAccount{}).Error
|
||||
|
||||
// 删除站点主体
|
||||
if err := tx.Delete(&site).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.evictSiteInfoCache(ctx, &site)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetSiteAdminPassword 重置分销站点绑定的管理员密码。
|
||||
func (r *Repository) ResetSiteAdminPassword(ctx context.Context, operatorAdminID, siteID uint64, newPassword string) error {
|
||||
if r == nil || r.db == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
global, err := r.hasGlobalDistributionAccess(ctx, operatorAdminID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !global && operatorAdminID != 0 {
|
||||
return ErrSiteAccessDenied
|
||||
}
|
||||
newPassword = strings.TrimSpace(newPassword)
|
||||
if len(newPassword) < 6 {
|
||||
return errors.New("新密码长度不能少于6位")
|
||||
}
|
||||
|
||||
var site model.DistributionSite
|
||||
if err := r.db.WithContext(ctx).First(&site, siteID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrSiteNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var members []model.DistributionSiteMember
|
||||
if err := r.db.WithContext(ctx).Where("site_id = ?", siteID).Find(&members).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(members) == 0 {
|
||||
return errors.New("该分销站点未绑定管理员账号")
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
adminUserIDs := make([]uint64, 0, len(members))
|
||||
for _, m := range members {
|
||||
adminUserIDs = append(adminUserIDs, m.AdminUserID)
|
||||
}
|
||||
|
||||
return r.db.WithContext(ctx).Model(&model.AdminUser{}).
|
||||
Where("id IN ?", adminUserIDs).
|
||||
Updates(map[string]interface{}{
|
||||
"password_hash": string(hash),
|
||||
"token_version": gorm.Expr("token_version + 1"),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) ListRules(ctx context.Context, adminID uint64, siteID *uint64, mode string) ([]RuleDTO, error) {
|
||||
db := r.db.WithContext(ctx).Model(&model.DistributionRuleVersion{}).
|
||||
Order("CASE WHEN site_id IS NULL THEN 0 ELSE 1 END ASC, site_id ASC, mode ASC, version DESC, id DESC")
|
||||
|
||||
@@ -117,6 +117,18 @@ func (s *Service) UpdateSite(ctx context.Context, adminID, id uint64, req Update
|
||||
}
|
||||
return s.repo.UpdateSite(ctx, adminID, id, req)
|
||||
}
|
||||
func (s *Service) DeleteSite(ctx context.Context, adminID, siteID uint64) error {
|
||||
if err := s.available(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.DeleteSite(ctx, adminID, siteID)
|
||||
}
|
||||
func (s *Service) ResetSiteAdminPassword(ctx context.Context, operatorAdminID, siteID uint64, newPassword string) error {
|
||||
if err := s.available(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.ResetSiteAdminPassword(ctx, operatorAdminID, siteID, newPassword)
|
||||
}
|
||||
func (s *Service) ListRules(ctx context.Context, adminID uint64, siteID *uint64, mode string) ([]RuleDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package distribution
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestSiteCRUD_UpdateSiteAndDomainUniqueness(t *testing.T) {
|
||||
db := distributionTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
ctx := t.Context()
|
||||
|
||||
_, err := repo.CreateSite(ctx, CreateSiteRequest{
|
||||
SiteCode: "site_crud_a",
|
||||
SiteName: "站点A",
|
||||
Domain: "a.domain.test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSite A failed: %v", err)
|
||||
}
|
||||
|
||||
siteB, err := repo.CreateSite(ctx, CreateSiteRequest{
|
||||
SiteCode: "site_crud_b",
|
||||
SiteName: "站点B",
|
||||
Domain: "b.domain.test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSite B failed: %v", err)
|
||||
}
|
||||
|
||||
// 1. 尝试将站点B的域名更新为已存在的站点A域名 -> 应该报错
|
||||
dupDomain := "a.domain.test"
|
||||
_, err = repo.UpdateSite(ctx, 0, siteB.ID, UpdateSiteRequest{
|
||||
Domain: &dupDomain,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when updating to duplicate domain, got nil")
|
||||
}
|
||||
|
||||
// 2. 正常更新站点B的名称、域名、状态为 disabled、展示主站账号为 false
|
||||
newName := "站点B更新后"
|
||||
newDomain := "b-new.domain.test"
|
||||
newStatus := "disabled"
|
||||
dispFalse := false
|
||||
updated, err := repo.UpdateSite(ctx, 0, siteB.ID, UpdateSiteRequest{
|
||||
SiteName: &newName,
|
||||
Domain: &newDomain,
|
||||
Status: &newStatus,
|
||||
DisplayAccountEnabled: &dispFalse,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateSite failed: %v", err)
|
||||
}
|
||||
if updated.SiteName != newName || updated.Domain != newDomain || updated.Status != "disabled" || updated.DisplayAccountEnabled != false {
|
||||
t.Fatalf("updated site values mismatch: %+v", updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteCRUD_DeleteSiteProtectionAndCascade(t *testing.T) {
|
||||
db := distributionTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
ctx := t.Context()
|
||||
|
||||
// 1. 使用 OnboardSite 创建一个完整的测试分销站(包含成员与管理员)
|
||||
onboardResp, err := repo.OnboardSite(ctx, OnboardSiteRequest{
|
||||
SiteCode: "del_test_site",
|
||||
SiteName: "待删除测试分销站",
|
||||
Domain: "del.domain.test",
|
||||
AdminUsername: "del_test_admin",
|
||||
AdminPassword: "password123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("OnboardSite failed: %v", err)
|
||||
}
|
||||
siteID := onboardResp.Site.ID
|
||||
adminUserID := onboardResp.Member.AdminID
|
||||
|
||||
// 2. 测试密码重置
|
||||
newPwd := "new_secret_pwd_666"
|
||||
if err := repo.ResetSiteAdminPassword(ctx, 0, siteID, newPwd); err != nil {
|
||||
t.Fatalf("ResetSiteAdminPassword failed: %v", err)
|
||||
}
|
||||
// 校验密码是否成功重置
|
||||
var adminUser model.AdminUser
|
||||
if err := db.First(&adminUser, adminUserID).Error; err != nil {
|
||||
t.Fatalf("failed to find admin user: %v", err)
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(adminUser.PasswordHash), []byte(newPwd)); err != nil {
|
||||
t.Fatalf("password hash does not match new password: %v", err)
|
||||
}
|
||||
|
||||
// 3. 验证 ListSites 与 FindSite 能够正确携带管理员信息
|
||||
sites, err := repo.ListSites(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSites failed: %v", err)
|
||||
}
|
||||
var foundSite *SiteDTO
|
||||
for i := range sites {
|
||||
if sites[i].ID == siteID {
|
||||
foundSite = &sites[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if foundSite == nil {
|
||||
t.Fatalf("site not found in ListSites")
|
||||
}
|
||||
if foundSite.AdminUsername != "del_test_admin" {
|
||||
t.Fatalf("expected admin_username del_test_admin, got %s", foundSite.AdminUsername)
|
||||
}
|
||||
|
||||
// 4. 测试有订单时的删除保护
|
||||
// 创建一笔模拟关联订单
|
||||
dummyOrder := model.RentalOrder{
|
||||
OrderNo: "ORD_DEL_TEST_001",
|
||||
RenterID: 999,
|
||||
AccountID: 888,
|
||||
DistributionSiteID: &siteID,
|
||||
}
|
||||
if err := db.Create(&dummyOrder).Error; err != nil {
|
||||
t.Fatalf("failed to create dummy order: %v", err)
|
||||
}
|
||||
|
||||
err = repo.DeleteSite(ctx, 0, siteID)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when deleting site with orders, got nil")
|
||||
}
|
||||
|
||||
// 删除此测试订单,继续测试级联删除
|
||||
if err := db.Delete(&dummyOrder).Error; err != nil {
|
||||
t.Fatalf("failed to remove dummy order: %v", err)
|
||||
}
|
||||
|
||||
// 5. 测试无订单时的干净级联删除
|
||||
if err := repo.DeleteSite(ctx, 0, siteID); err != nil {
|
||||
t.Fatalf("DeleteSite failed: %v", err)
|
||||
}
|
||||
|
||||
// 校验站点主体已删除
|
||||
var checkSite model.DistributionSite
|
||||
if err := db.First(&checkSite, siteID).Error; err == nil {
|
||||
t.Fatalf("site should be deleted from db")
|
||||
}
|
||||
|
||||
// 校验专属管理员用户已同步清理
|
||||
var checkAdmin model.AdminUser
|
||||
if err := db.First(&checkAdmin, adminUserID).Error; err == nil {
|
||||
t.Fatalf("dedicated admin user should be deleted from db")
|
||||
}
|
||||
|
||||
// 校验规则已清理
|
||||
var ruleCount int64
|
||||
db.Model(&model.DistributionRuleVersion{}).Where("site_id = ?", siteID).Count(&ruleCount)
|
||||
if ruleCount > 0 {
|
||||
t.Fatalf("expected 0 rules for deleted site, got %d", ruleCount)
|
||||
}
|
||||
}
|
||||
@@ -629,6 +629,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
distributionProtectedRoutes.POST("/auth/logout", distributionHandler.Logout)
|
||||
distributionProtectedRoutes.POST("/sites/:id/sync-accounts", distributionHandler.SyncSiteAccounts)
|
||||
distributionProtectedRoutes.PUT("/sites/:id", distributionHandler.UpdateSite)
|
||||
distributionProtectedRoutes.DELETE("/sites/:id", distributionHandler.DeleteSite)
|
||||
distributionProtectedRoutes.PUT("/sites/:id/branding", distributionHandler.UpdateSiteBranding)
|
||||
distributionProtectedRoutes.GET("/sites/:id/wallet", distributionHandler.GetSiteWallet)
|
||||
distributionProtectedRoutes.POST("/sites/:id/withdrawals", distributionHandler.ApplySiteWithdrawal)
|
||||
@@ -657,6 +658,8 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
adminRoutes.POST("/distribution/sites/onboard", requirePerm("distribution:site_manage"), distributionHandler.OnboardSite)
|
||||
adminRoutes.POST("/distribution/sites/:id/sync-accounts", requirePerm("distribution:account_display"), distributionHandler.SyncSiteAccounts)
|
||||
adminRoutes.PUT("/distribution/sites/:id", requirePerm("distribution:site_manage"), distributionHandler.UpdateSite)
|
||||
adminRoutes.DELETE("/distribution/sites/:id", requirePerm("distribution:site_manage"), distributionHandler.DeleteSite)
|
||||
adminRoutes.PUT("/distribution/sites/:id/admin-password", requirePerm("distribution:site_manage"), distributionHandler.ResetSiteAdminPassword)
|
||||
adminRoutes.PUT("/distribution/sites/:id/branding", requirePerm("distribution:site_manage"), distributionHandler.UpdateSiteBranding)
|
||||
adminRoutes.GET("/distribution/sites/:id/sms-config", requirePerm("distribution:site_view"), distributionHandler.GetSiteSMSConfig)
|
||||
adminRoutes.PUT("/distribution/sites/:id/sms-config", requirePerm("distribution:site_manage"), distributionHandler.UpdateSiteSMSConfig)
|
||||
|
||||
@@ -130,6 +130,10 @@ export interface Site {
|
||||
listing_qrcode_url?: string
|
||||
receive_ratio_delta?: number
|
||||
receive_cost_delta?: number
|
||||
admin_user_id?: number
|
||||
admin_username?: string
|
||||
admin_nickname?: string
|
||||
member_type?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
@@ -341,9 +345,35 @@ export async function listSites(): Promise<Site[]> {
|
||||
|
||||
export async function updateSite(
|
||||
siteID: number,
|
||||
payload: Partial<Pick<Site, 'receive_ratio_delta' | 'receive_cost_delta'>>
|
||||
payload: Partial<
|
||||
Pick<
|
||||
Site,
|
||||
| 'site_name'
|
||||
| 'domain'
|
||||
| 'status'
|
||||
| 'display_account_enabled'
|
||||
| 'tax_enabled'
|
||||
| 'tax_rate_bps'
|
||||
| 'settlement_hold_days'
|
||||
| 'listing_qrcode_url'
|
||||
| 'receive_ratio_delta'
|
||||
| 'receive_cost_delta'
|
||||
>
|
||||
>
|
||||
): Promise<Site> {
|
||||
const resp = await api.put<ApiResponse<Site>>(`/distribution/sites/${siteID}`, payload)
|
||||
const resp = await api.put<ApiResponse<Site>>(`/admin/distribution/sites/${siteID}`, payload)
|
||||
return extract(resp)
|
||||
}
|
||||
|
||||
export async function deleteSite(siteID: number): Promise<{ deleted: boolean }> {
|
||||
const resp = await api.delete<ApiResponse<{ deleted: boolean }>>(`/admin/distribution/sites/${siteID}`)
|
||||
return extract(resp)
|
||||
}
|
||||
|
||||
export async function resetSiteAdminPassword(siteID: number, newPassword: string): Promise<{ message: string }> {
|
||||
const resp = await api.put<ApiResponse<{ message: string }>>(`/admin/distribution/sites/${siteID}/admin-password`, {
|
||||
new_password: newPassword,
|
||||
})
|
||||
return extract(resp)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
@@ -12,12 +12,34 @@ import {
|
||||
InputNumber,
|
||||
Select,
|
||||
Switch,
|
||||
Radio,
|
||||
message,
|
||||
Divider,
|
||||
Descriptions,
|
||||
Popconfirm,
|
||||
Tooltip,
|
||||
} from 'antd'
|
||||
import { PlusOutlined, ShopOutlined, CopyOutlined } from '@ant-design/icons'
|
||||
import { listSites, onboardSite, fetchDistributionSiteDefaults, type Site, type OnboardSitePayload, type OnboardSiteResult } from '../api'
|
||||
import {
|
||||
PlusOutlined,
|
||||
ShopOutlined,
|
||||
CopyOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
KeyOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
listSites,
|
||||
onboardSite,
|
||||
updateSite,
|
||||
deleteSite,
|
||||
resetSiteAdminPassword,
|
||||
fetchDistributionSiteDefaults,
|
||||
type Site,
|
||||
type OnboardSitePayload,
|
||||
type OnboardSiteResult,
|
||||
} from '../api'
|
||||
|
||||
export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean }) {
|
||||
const [items, setItems] = useState<Site[]>([])
|
||||
@@ -28,6 +50,22 @@ export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean
|
||||
const [defaultSettlementHoldDays, setDefaultSettlementHoldDays] = useState(7)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
// 筛选检索
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('')
|
||||
|
||||
// 编辑弹窗
|
||||
const [editModalOpen, setEditModalOpen] = useState(false)
|
||||
const [editSubmitting, setEditSubmitting] = useState(false)
|
||||
const [editingSite, setEditingSite] = useState<Site | null>(null)
|
||||
const [editForm] = Form.useForm()
|
||||
|
||||
// 重置密码弹窗
|
||||
const [resetPwdModalOpen, setResetPwdModalOpen] = useState(false)
|
||||
const [resetPwdSubmitting, setResetPwdSubmitting] = useState(false)
|
||||
const [resetPwdSite, setResetPwdSite] = useState<Site | null>(null)
|
||||
const [resetPwdForm] = Form.useForm()
|
||||
|
||||
const loadData = () => {
|
||||
setLoading(true)
|
||||
listSites()
|
||||
@@ -50,6 +88,25 @@ export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean
|
||||
})
|
||||
}, [])
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
return items.filter((site) => {
|
||||
if (statusFilter && site.status !== statusFilter) {
|
||||
return false
|
||||
}
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.trim().toLowerCase()
|
||||
const matchName = site.site_name?.toLowerCase().includes(q)
|
||||
const matchCode = site.site_code?.toLowerCase().includes(q)
|
||||
const matchDomain = site.domain?.toLowerCase().includes(q)
|
||||
const matchAdmin = site.admin_username?.toLowerCase().includes(q)
|
||||
if (!matchName && !matchCode && !matchDomain && !matchAdmin) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [items, searchQuery, statusFilter])
|
||||
|
||||
const handleOpenModal = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({
|
||||
@@ -91,6 +148,90 @@ export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenEdit = (site: Site) => {
|
||||
setEditingSite(site)
|
||||
editForm.resetFields()
|
||||
editForm.setFieldsValue({
|
||||
site_name: site.site_name,
|
||||
domain: site.domain,
|
||||
status: site.status || 'active',
|
||||
display_account_enabled: site.display_account_enabled,
|
||||
settlement_hold_days: site.settlement_hold_days ?? defaultSettlementHoldDays,
|
||||
receive_cost_delta: Number(site.receive_cost_delta || 0),
|
||||
tax_enabled: site.tax_enabled,
|
||||
tax_rate_bps: site.tax_rate_bps ?? 100,
|
||||
})
|
||||
setEditModalOpen(true)
|
||||
}
|
||||
|
||||
const handleEditSubmit = async (values: any) => {
|
||||
if (!editingSite) return
|
||||
setEditSubmitting(true)
|
||||
try {
|
||||
await updateSite(editingSite.id, {
|
||||
site_name: values.site_name.trim(),
|
||||
domain: values.domain.trim(),
|
||||
status: values.status,
|
||||
display_account_enabled: values.display_account_enabled,
|
||||
settlement_hold_days: Number(values.settlement_hold_days),
|
||||
receive_cost_delta: Number(values.receive_cost_delta),
|
||||
tax_enabled: values.tax_enabled,
|
||||
tax_rate_bps: values.tax_enabled ? values.tax_rate_bps : 0,
|
||||
})
|
||||
message.success(`【${values.site_name}】分销站点更新成功`)
|
||||
setEditModalOpen(false)
|
||||
loadData()
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.message || '更新分销站点失败'
|
||||
message.error(msg)
|
||||
} finally {
|
||||
setEditSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleStatus = async (site: Site) => {
|
||||
const isCurrentlyActive = site.status === 'active'
|
||||
const newStatus = isCurrentlyActive ? 'disabled' : 'active'
|
||||
const actionText = isCurrentlyActive ? '停用' : '启用'
|
||||
try {
|
||||
await updateSite(site.id, { status: newStatus })
|
||||
message.success(`分销站点【${site.site_name}】已${actionText}`)
|
||||
loadData()
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.message || `${actionText}失败`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteSite = async (site: Site) => {
|
||||
try {
|
||||
await deleteSite(site.id)
|
||||
message.success(`分销站点【${site.site_name}】已删除`)
|
||||
loadData()
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.message || '删除分销站点失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenResetPwd = (site: Site) => {
|
||||
setResetPwdSite(site)
|
||||
resetPwdForm.resetFields()
|
||||
setResetPwdModalOpen(true)
|
||||
}
|
||||
|
||||
const handleResetPwdSubmit = async (values: any) => {
|
||||
if (!resetPwdSite) return
|
||||
setResetPwdSubmitting(true)
|
||||
try {
|
||||
await resetSiteAdminPassword(resetPwdSite.id, values.new_password)
|
||||
message.success(`【${resetPwdSite.site_name}】管理员密码已成功重置`)
|
||||
setResetPwdModalOpen(false)
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.message || '密码重置失败')
|
||||
} finally {
|
||||
setResetPwdSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" className="page" size="large" style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
@@ -99,28 +240,65 @@ export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean
|
||||
分销网站管理
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
管理全平台合作分销站点、域名配置与分销商管理员账号
|
||||
管理全平台合作分销站点、域名配置、结算规则与分销商管理员账号
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{isSuperadmin && (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleOpenModal}>
|
||||
开通分销商
|
||||
一键开通分销商
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
{/* 筛选检索区 */}
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 16, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Input
|
||||
placeholder="搜索站点名称 / 标识 / 域名 / 管理员"
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 280 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="运行状态"
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
style={{ width: 130 }}
|
||||
options={[
|
||||
{ label: '全部状态', value: '' },
|
||||
{ label: '正常运营', value: 'active' },
|
||||
{ label: '已停用', value: 'disabled' },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSearchQuery('')
|
||||
setStatusFilter('')
|
||||
}}
|
||||
>
|
||||
重置筛选
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={loadData}>
|
||||
刷新
|
||||
</Button>
|
||||
<div style={{ marginLeft: 'auto', color: '#666', fontSize: 13 }}>
|
||||
共 <strong>{filteredItems.length}</strong> 个分销站点
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
dataSource={items}
|
||||
dataSource={filteredItems}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 950 }}
|
||||
scroll={{ x: 1100 }}
|
||||
columns={[
|
||||
{
|
||||
title: '站点名称',
|
||||
dataIndex: 'site_name',
|
||||
width: 220,
|
||||
width: 180,
|
||||
render: (v: string) => (
|
||||
<Space>
|
||||
<ShopOutlined style={{ color: '#1677ff' }} />
|
||||
@@ -131,61 +309,249 @@ export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean
|
||||
{
|
||||
title: '站点标识',
|
||||
dataIndex: 'site_code',
|
||||
width: 140,
|
||||
width: 120,
|
||||
render: (v: string) => <Tag color="blue">{v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '绑定域名',
|
||||
dataIndex: 'domain',
|
||||
width: 220,
|
||||
width: 200,
|
||||
render: (v: string) => (
|
||||
<Typography.Text copyable={{ text: v }} className="mono-code" style={{ fontSize: 13 }}>
|
||||
{v}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '分销管理员',
|
||||
width: 140,
|
||||
render: (_: unknown, row: Site) =>
|
||||
row.admin_username ? (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Tag color="cyan">{row.admin_username}</Tag>
|
||||
{row.admin_nickname && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
||||
({row.admin_nickname})
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
) : (
|
||||
<span style={{ color: '#999' }}>未关联</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '运行状态',
|
||||
dataIndex: 'status',
|
||||
width: 110,
|
||||
render: (v: string) => (
|
||||
<Tag color={v === 'active' ? 'green' : 'default'}>
|
||||
{v === 'active' ? '正常运营' : v}
|
||||
</Tag>
|
||||
),
|
||||
render: (v: string, row: Site) =>
|
||||
isSuperadmin ? (
|
||||
<Tooltip title={v === 'active' ? '点击停用站点' : '点击启用站点'}>
|
||||
<Switch
|
||||
checked={v === 'active'}
|
||||
checkedChildren="正常"
|
||||
unCheckedChildren="停用"
|
||||
onChange={() => handleToggleStatus(row)}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tag color={v === 'active' ? 'green' : 'default'}>
|
||||
{v === 'active' ? '正常' : '已停用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '账号展示',
|
||||
dataIndex: 'display_account_enabled',
|
||||
width: 100,
|
||||
width: 90,
|
||||
render: (v: boolean) => (v ? <Tag color="green">开启</Tag> : <Tag>关闭</Tag>),
|
||||
},
|
||||
{
|
||||
title: '税费设定',
|
||||
width: 110,
|
||||
width: 100,
|
||||
render: (_: unknown, row: Site) =>
|
||||
row.tax_enabled ? <Tag color="orange">{row.tax_rate_bps / 100}%</Tag> : <Tag>不征税</Tag>,
|
||||
row.tax_enabled ? <Tag color="orange">{row.tax_rate_bps / 100}%</Tag> : <Tag>免税</Tag>,
|
||||
},
|
||||
{
|
||||
title: '结算冷却',
|
||||
width: 90,
|
||||
render: (_: unknown, row: Site) =>
|
||||
row.settlement_hold_days && row.settlement_hold_days > 0 ? (
|
||||
<Tag color="blue">{row.settlement_hold_days}天</Tag>
|
||||
) : (
|
||||
<Tag color="green">立即</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '收号压价',
|
||||
width: 120,
|
||||
width: 110,
|
||||
render: (_: unknown, row: Site) =>
|
||||
Number(row.receive_cost_delta || 0) > 0 ? (
|
||||
<Tag color="gold">低 {Number(row.receive_cost_delta).toFixed(1)} 点</Tag>
|
||||
) : (
|
||||
<Tag>与主站一致</Tag>
|
||||
<Tag>同主站</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 140,
|
||||
width: 120,
|
||||
render: (v: string) => (v ? new Date(v).toLocaleDateString() : '-'),
|
||||
},
|
||||
...(isSuperadmin
|
||||
? [
|
||||
{
|
||||
title: '操作',
|
||||
width: 220,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, row: Site) => (
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleOpenEdit(row)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<KeyOutlined />}
|
||||
onClick={() => handleOpenResetPwd(row)}
|
||||
>
|
||||
重置密码
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认删除此分销站点?"
|
||||
description="此操作将清理该站点及其规则与专属账号。若已产生订单则无法删除。"
|
||||
onConfirm={() => handleDeleteSite(row)}
|
||||
okText="确认删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 编辑分销站点 Modal */}
|
||||
<Modal
|
||||
title={editingSite ? `编辑分销站点: ${editingSite.site_name}` : '编辑分销站点'}
|
||||
open={editModalOpen}
|
||||
onCancel={() => setEditModalOpen(false)}
|
||||
footer={null}
|
||||
width={600}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={editForm} layout="vertical" onFinish={handleEditSubmit} style={{ marginTop: 16 }}>
|
||||
<Space direction="horizontal" style={{ width: '100%' }} size="middle">
|
||||
<Form.Item label="站点唯一标识" style={{ flex: 1 }}>
|
||||
<Tag color="blue" style={{ fontSize: 13, padding: '4px 8px' }}>
|
||||
{editingSite?.site_code}
|
||||
</Tag>
|
||||
</Form.Item>
|
||||
<Form.Item label="管理员账号" style={{ flex: 1 }}>
|
||||
<Tag style={{ fontSize: 13, padding: '4px 8px' }}>
|
||||
{editingSite?.admin_username || '未关联'}
|
||||
</Tag>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Form.Item
|
||||
label="分销站点名称"
|
||||
name="site_name"
|
||||
rules={[{ required: true, message: '请输入分销站点名称' }]}
|
||||
>
|
||||
<Input placeholder="例如:广州游戏分销站" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="绑定独立域名"
|
||||
name="domain"
|
||||
rules={[{ required: true, message: '请输入绑定专属域名' }]}
|
||||
>
|
||||
<Input placeholder="例如:gz.hao858.com" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="运行状态" name="status">
|
||||
<Radio.Group>
|
||||
<Radio value="active">正常运营</Radio>
|
||||
<Radio value="disabled">暂停服务 (已停用)</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Space direction="horizontal" style={{ width: '100%' }} size="middle">
|
||||
<Form.Item label="展示主站账号" name="display_account_enabled" valuePropName="checked" style={{ flex: 1 }}>
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||
</Form.Item>
|
||||
<Form.Item label="是否征税" name="tax_enabled" valuePropName="checked" style={{ flex: 1 }}>
|
||||
<Switch checkedChildren="征税" unCheckedChildren="免税" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Space direction="horizontal" style={{ width: '100%' }} size="middle">
|
||||
<Form.Item label="结算冷却期 (天)" name="settlement_hold_days" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} max={365} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="收号压价 (点)" name="receive_cost_delta" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} max={20} step={0.1} precision={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 24 }}>
|
||||
<Button onClick={() => setEditModalOpen(false)}>取消</Button>
|
||||
<Button type="primary" htmlType="submit" loading={editSubmitting}>
|
||||
保存设置
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 重置管理员密码 Modal */}
|
||||
<Modal
|
||||
title={`重置管理员密码: ${resetPwdSite?.site_name}`}
|
||||
open={resetPwdModalOpen}
|
||||
onCancel={() => setResetPwdModalOpen(false)}
|
||||
footer={null}
|
||||
width={450}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={resetPwdForm} layout="vertical" onFinish={handleResetPwdSubmit} style={{ marginTop: 16 }}>
|
||||
<Descriptions column={1} size="small" bordered style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="站点名称">{resetPwdSite?.site_name}</Descriptions.Item>
|
||||
<Descriptions.Item label="管理员账号">
|
||||
<Tag color="cyan">{resetPwdSite?.admin_username || '专属管理员'}</Tag>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Form.Item
|
||||
label="新登录密码"
|
||||
name="new_password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 6, message: '密码长度至少6位' },
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="至少6位新登录密码" />
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 24 }}>
|
||||
<Button onClick={() => setResetPwdModalOpen(false)}>取消</Button>
|
||||
<Button type="primary" htmlType="submit" loading={resetPwdSubmitting}>
|
||||
确认重置
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 开通分销商 Modal */}
|
||||
<Modal
|
||||
title="开通分销商 (一键初始化站点与账号)"
|
||||
@@ -255,47 +621,58 @@ export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean
|
||||
</Space>
|
||||
|
||||
<Space direction="horizontal" style={{ width: '100%' }} size="middle">
|
||||
<Form.Item label="管理员姓名/昵称" name="admin_nickname" style={{ flex: 1 }}>
|
||||
<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' },
|
||||
]}
|
||||
/>
|
||||
<Select>
|
||||
<Select.Option value="operator">分销运营 (operator)</Select.Option>
|
||||
<Select.Option value="finance">分销财务 (finance)</Select.Option>
|
||||
<Select.Option value="customer_service">分销客服 (customer_service)</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Divider style={{ margin: '16px 0' }} />
|
||||
|
||||
<Typography.Title level={5}>3. 结算冷却期与展示</Typography.Title>
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: -8 }}>
|
||||
开通后四种分成模式均按默认生效:收号 35% / 卖出 40% / 服务费 25%(本站自卖 75% + 25%)。之后可在规则页调整。
|
||||
<Typography.Title level={5}>3. 结算冷却期与展示配置</Typography.Title>
|
||||
<Typography.Paragraph type="secondary" style={{ fontSize: 13, marginBottom: 12 }}>
|
||||
开通后四种模式均按默认生效:收号 35% / 卖出 40% / 服务费 25%(自卖为 75% + 25%)。之后可在「分销规则」页单独调整。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Space direction="horizontal" style={{ width: '100%' }} size="middle">
|
||||
<Form.Item label="结算冷却期 (天)" name="settlement_hold_days" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} max={365} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="结算冷却期 (天)"
|
||||
name="settlement_hold_days"
|
||||
tooltip="订单完成后进入冷却期,期满自动释放到可提现余额;0 表示完成即释放"
|
||||
label="展示主站账号"
|
||||
name="display_account_enabled"
|
||||
valuePropName="checked"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<InputNumber min={0} max={365} step={1} style={{ width: '100%' }} />
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Space direction="horizontal" size="large" style={{ marginTop: 8 }}>
|
||||
<Form.Item
|
||||
label="允许展示主站账号"
|
||||
name="display_account_enabled"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch checkedChildren="开" unCheckedChildren="关" />
|
||||
<Space direction="horizontal" style={{ width: '100%' }} size="middle">
|
||||
<Form.Item label="是否征税" name="tax_enabled" valuePropName="checked" style={{ flex: 1 }}>
|
||||
<Switch checkedChildren="征税" unCheckedChildren="不征税" />
|
||||
</Form.Item>
|
||||
<Form.Item label="是否征税" name="tax_enabled" valuePropName="checked">
|
||||
<Switch checkedChildren="征税" unCheckedChildren="免税" />
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) => prev.tax_enabled !== curr.tax_enabled}
|
||||
>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue('tax_enabled') ? (
|
||||
<Form.Item
|
||||
label="综合税率 (基点,100 = 1%)"
|
||||
name="tax_rate_bps"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<InputNumber min={0} max={5000} step={50} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ export interface DistributionSite {
|
||||
listing_qrcode_url?: string
|
||||
receive_ratio_delta?: number
|
||||
receive_cost_delta?: number
|
||||
admin_user_id?: number
|
||||
admin_username?: string
|
||||
admin_nickname?: string
|
||||
member_type?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
@@ -166,6 +170,21 @@ export async function updateDistributionSite(
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function deleteDistributionSite(id: number) {
|
||||
const { data } = await apiClient.delete<ApiResponse<{ deleted: boolean }>>(
|
||||
`/admin/distribution/sites/${id}`
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function resetDistributionSiteAdminPassword(id: number, newPassword: string) {
|
||||
const { data } = await apiClient.put<ApiResponse<{ message: string }>>(
|
||||
`/admin/distribution/sites/${id}/admin-password`,
|
||||
{ new_password: newPassword }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchDistributionRules(siteId?: number, mode?: string) {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: DistributionRule[] }>>(
|
||||
'/admin/distribution/rules',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import {
|
||||
Plus,
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
Upload,
|
||||
Coin,
|
||||
Message,
|
||||
Edit,
|
||||
Delete,
|
||||
Search,
|
||||
Key,
|
||||
ArrowDown,
|
||||
} from '@element-plus/icons-vue'
|
||||
import {
|
||||
fetchDistributionSites,
|
||||
@@ -19,6 +24,8 @@ import {
|
||||
onboardDistributionSite,
|
||||
syncDistributionSiteAccounts,
|
||||
updateDistributionSite,
|
||||
deleteDistributionSite,
|
||||
resetDistributionSiteAdminPassword,
|
||||
fetchDistributionSiteSMSConfig,
|
||||
updateDistributionSiteSMSConfig,
|
||||
testDistributionSiteSMSConfig,
|
||||
@@ -39,6 +46,47 @@ const loading = ref(false)
|
||||
const sites = ref<DistributionSite[]>([])
|
||||
const defaultSettlementHoldDays = ref(7)
|
||||
|
||||
// 筛选检索状态
|
||||
const searchQuery = ref('')
|
||||
const statusFilter = ref('')
|
||||
const displayAccountFilter = ref('')
|
||||
|
||||
const filteredSites = computed(() => {
|
||||
return sites.value.filter(site => {
|
||||
if (statusFilter.value && site.status !== statusFilter.value) {
|
||||
return false
|
||||
}
|
||||
if (displayAccountFilter.value !== '') {
|
||||
const want = displayAccountFilter.value === 'true'
|
||||
if (site.display_account_enabled !== want) return false
|
||||
}
|
||||
if (searchQuery.value.trim()) {
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
const matchName = site.site_name?.toLowerCase().includes(q)
|
||||
const matchCode = site.site_code?.toLowerCase().includes(q)
|
||||
const matchDomain = site.domain?.toLowerCase().includes(q)
|
||||
const matchAdmin =
|
||||
site.admin_username?.toLowerCase().includes(q) ||
|
||||
site.admin_nickname?.toLowerCase().includes(q)
|
||||
if (!matchName && !matchCode && !matchDomain && !matchAdmin) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
function handleResetFilter() {
|
||||
searchQuery.value = ''
|
||||
statusFilter.value = ''
|
||||
displayAccountFilter.value = ''
|
||||
}
|
||||
|
||||
function copyDomain(domain: string) {
|
||||
navigator.clipboard?.writeText(domain)
|
||||
ElMessage.success('域名已复制到剪贴板')
|
||||
}
|
||||
|
||||
const onboardDialogVisible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const onboardFormRef = ref<FormInstance>()
|
||||
@@ -452,6 +500,160 @@ async function handleSyncAccounts(row: DistributionSite) {
|
||||
}
|
||||
}
|
||||
|
||||
// 站点编辑状态
|
||||
const editDialogVisible = ref(false)
|
||||
const editSubmitting = ref(false)
|
||||
const editFormRef = ref<FormInstance>()
|
||||
const editForm = reactive({
|
||||
id: 0,
|
||||
site_code: '',
|
||||
site_name: '',
|
||||
domain: '',
|
||||
status: 'active',
|
||||
display_account_enabled: true,
|
||||
settlement_hold_days: defaultSettlementHoldDays.value,
|
||||
receive_cost_delta: 0,
|
||||
tax_enabled: false,
|
||||
tax_rate_bps: 100,
|
||||
admin_username: '',
|
||||
})
|
||||
|
||||
const editRules: FormRules = {
|
||||
site_name: [{ required: true, message: '请输入分销站点名称', trigger: 'blur' }],
|
||||
domain: [{ required: true, message: '请输入分销专属域名', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function handleOpenEdit(row: DistributionSite) {
|
||||
editForm.id = row.id
|
||||
editForm.site_code = row.site_code
|
||||
editForm.site_name = row.site_name
|
||||
editForm.domain = row.domain
|
||||
editForm.status = row.status || 'active'
|
||||
editForm.display_account_enabled = !!row.display_account_enabled
|
||||
editForm.settlement_hold_days = row.settlement_hold_days ?? defaultSettlementHoldDays.value
|
||||
editForm.receive_cost_delta = Number(row.receive_cost_delta || 0)
|
||||
editForm.tax_enabled = !!row.tax_enabled
|
||||
editForm.tax_rate_bps = row.tax_rate_bps ?? 100
|
||||
editForm.admin_username = row.admin_username || ''
|
||||
editDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleEditSubmit() {
|
||||
if (!editFormRef.value) return
|
||||
await editFormRef.value.validate(async valid => {
|
||||
if (!valid) return
|
||||
editSubmitting.value = true
|
||||
try {
|
||||
await updateDistributionSite(editForm.id, {
|
||||
site_name: editForm.site_name.trim(),
|
||||
domain: editForm.domain.trim(),
|
||||
status: editForm.status,
|
||||
display_account_enabled: editForm.display_account_enabled,
|
||||
settlement_hold_days: Number(editForm.settlement_hold_days),
|
||||
receive_cost_delta: Number(editForm.receive_cost_delta),
|
||||
tax_enabled: editForm.tax_enabled,
|
||||
tax_rate_bps: editForm.tax_enabled ? Number(editForm.tax_rate_bps) : 0,
|
||||
})
|
||||
ElMessage.success(`【${editForm.site_name}】分销站点信息已更新`)
|
||||
editDialogVisible.value = false
|
||||
await loadSites()
|
||||
} catch (err) {
|
||||
ElMessage.error(readError(err, '更新分销站点信息失败'))
|
||||
} finally {
|
||||
editSubmitting.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 快速启停状态
|
||||
async function handleToggleStatus(row: DistributionSite) {
|
||||
const isCurrentlyActive = row.status === 'active'
|
||||
const newStatus = isCurrentlyActive ? 'disabled' : 'active'
|
||||
const actionText = isCurrentlyActive ? '停用' : '启用'
|
||||
const confirmMsg = isCurrentlyActive
|
||||
? `确认停用分销站点【${row.site_name}】?停用后该站点前台将暂停服务,无法进行租号与收号。`
|
||||
: `确认重新启用分销站点【${row.site_name}】?启用后该站点将恢复正常对外运营。`
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(confirmMsg, `确认${actionText}分销站点`, {
|
||||
confirmButtonText: `确认${actionText}`,
|
||||
cancelButtonText: '取消',
|
||||
type: isCurrentlyActive ? 'warning' : 'info',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await updateDistributionSite(row.id, { status: newStatus })
|
||||
ElMessage.success(`分销站点【${row.site_name}】已${actionText}`)
|
||||
await loadSites()
|
||||
} catch (err) {
|
||||
ElMessage.error(readError(err, `${actionText}分销站点失败`))
|
||||
}
|
||||
}
|
||||
|
||||
// 重置分销商密码状态
|
||||
const resetPwdDialogVisible = ref(false)
|
||||
const resetPwdSubmitting = ref(false)
|
||||
const resetPwdForm = reactive({
|
||||
site_id: 0,
|
||||
site_name: '',
|
||||
admin_username: '',
|
||||
new_password: '',
|
||||
})
|
||||
|
||||
function handleOpenResetPassword(row: DistributionSite) {
|
||||
resetPwdForm.site_id = row.id
|
||||
resetPwdForm.site_name = row.site_name
|
||||
resetPwdForm.admin_username = row.admin_username || '专属管理员'
|
||||
resetPwdForm.new_password = ''
|
||||
resetPwdDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleResetPasswordSubmit() {
|
||||
if (!resetPwdForm.new_password || resetPwdForm.new_password.length < 6) {
|
||||
ElMessage.warning('新密码长度不能少于6位')
|
||||
return
|
||||
}
|
||||
resetPwdSubmitting.value = true
|
||||
try {
|
||||
await resetDistributionSiteAdminPassword(resetPwdForm.site_id, resetPwdForm.new_password)
|
||||
ElMessage.success(`【${resetPwdForm.site_name}】管理员密码重置成功!`)
|
||||
resetPwdDialogVisible.value = false
|
||||
} catch (err) {
|
||||
ElMessage.error(readError(err, '重置分销商密码失败'))
|
||||
} finally {
|
||||
resetPwdSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除分销站点
|
||||
async function handleDeleteSite(row: DistributionSite) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除分销站点【${row.site_name}】(${row.site_code}) 吗?\n此操作将永久清理该分销站及其规则、上架关系与专属管理员账号。\n若该分销站点已产生业务订单或分账流水,系统将予以拦截保护。`,
|
||||
'警告:确认删除分销站点',
|
||||
{
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'error',
|
||||
confirmButtonClass: 'el-button--danger',
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteDistributionSite(row.id)
|
||||
ElMessage.success(`分销站点【${row.site_name}】已成功删除`)
|
||||
await loadSites()
|
||||
} catch (err) {
|
||||
ElMessage.error(readError(err, '删除分销站点失败'))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSites()
|
||||
fetchDistributionSiteDefaults()
|
||||
@@ -486,7 +688,38 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<el-table v-loading="loading" :data="sites" stripe style="width: 100%">
|
||||
<!-- 搜索筛选栏 -->
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索站点名称 / 标识 / 域名 / 管理员"
|
||||
clearable
|
||||
:prefix-icon="Search"
|
||||
style="width: 280px"
|
||||
/>
|
||||
<el-select v-model="statusFilter" placeholder="运行状态" clearable style="width: 130px">
|
||||
<el-option label="全部状态" value="" />
|
||||
<el-option label="正常运营" value="active" />
|
||||
<el-option label="已停用" value="disabled" />
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="displayAccountFilter"
|
||||
placeholder="展示主站账号"
|
||||
clearable
|
||||
style="width: 140px"
|
||||
>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="已开启" value="true" />
|
||||
<el-option label="已关闭" value="false" />
|
||||
</el-select>
|
||||
<el-button @click="handleResetFilter">重置筛选</el-button>
|
||||
<el-button :icon="Refresh" @click="loadSites">刷新</el-button>
|
||||
<div class="filter-stats">
|
||||
共 <strong>{{ filteredSites.length }}</strong> 个分销站点
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="filteredSites" stripe style="width: 100%">
|
||||
<el-table-column label="站点名称" prop="site_name" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<div class="site-name-cell">
|
||||
@@ -500,22 +733,55 @@ onMounted(() => {
|
||||
<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">
|
||||
<el-table-column label="绑定域名" prop="domain" min-width="190">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 'active' ? '正常' : '已停用' }}
|
||||
</el-tag>
|
||||
<div class="domain-cell">
|
||||
<span class="domain-text">{{ row.domain }}</span>
|
||||
<el-button
|
||||
:icon="DocumentCopy"
|
||||
link
|
||||
size="small"
|
||||
title="复制域名"
|
||||
@click="copyDomain(row.domain)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="展示主站账号" prop="display_account_enabled" width="130">
|
||||
<el-table-column label="分销管理员" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.admin_username" class="admin-cell">
|
||||
<el-tag size="small" type="info">{{ row.admin_username }}</el-tag>
|
||||
<span v-if="row.admin_nickname" class="admin-nickname"
|
||||
>({{ row.admin_nickname }})</span
|
||||
>
|
||||
</div>
|
||||
<span v-else class="text-muted">未关联</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="运行状态" prop="status" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip
|
||||
:content="row.status === 'active' ? '点击可停用分销站点' : '点击可启用分销站点'"
|
||||
placement="top"
|
||||
>
|
||||
<el-switch
|
||||
:model-value="row.status === 'active'"
|
||||
inline-prompt
|
||||
active-text="正常"
|
||||
inactive-text="停用"
|
||||
@click.prevent="handleToggleStatus(row)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="展示主站账号" prop="display_account_enabled" width="120">
|
||||
<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">
|
||||
<el-table-column label="税费设定" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.tax_enabled" type="warning" size="small">
|
||||
{{ (row.tax_rate_bps / 100).toFixed(1) }}%
|
||||
@@ -561,8 +827,11 @@ onMounted(() => {
|
||||
{{ formatDateTime(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="410" fixed="right">
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link :icon="Edit" @click="handleOpenEdit(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
link
|
||||
@@ -572,18 +841,38 @@ onMounted(() => {
|
||||
>
|
||||
同步商品
|
||||
</el-button>
|
||||
<el-button type="primary" link :icon="Clock" @click="handleOpenSettlement(row)">
|
||||
结算设置
|
||||
</el-button>
|
||||
<el-button type="primary" link :icon="Coin" @click="handleOpenReceiveRatio(row)">
|
||||
收号比例
|
||||
</el-button>
|
||||
<el-button type="primary" link :icon="Picture" @click="handleOpenQrcode(row)">
|
||||
二维码
|
||||
</el-button>
|
||||
<el-button type="primary" link :icon="Message" @click="handleOpenSMS(row)">
|
||||
短信配置
|
||||
</el-button>
|
||||
<el-dropdown trigger="click">
|
||||
<el-button type="primary" link>
|
||||
更多<el-icon class="el-icon--right"><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item :icon="Clock" @click="handleOpenSettlement(row)">
|
||||
结算设置
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item :icon="Coin" @click="handleOpenReceiveRatio(row)">
|
||||
收号比例
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item :icon="Picture" @click="handleOpenQrcode(row)">
|
||||
发布二维码
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item :icon="Message" @click="handleOpenSMS(row)">
|
||||
短信配置
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item :icon="Key" @click="handleOpenResetPassword(row)">
|
||||
重置管理员密码
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
divided
|
||||
:icon="Delete"
|
||||
style="color: #f56c6c"
|
||||
@click="handleDeleteSite(row)"
|
||||
>
|
||||
删除站点
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -926,6 +1215,144 @@ onMounted(() => {
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 编辑分销站点对话框 -->
|
||||
<el-dialog v-model="editDialogVisible" title="编辑分销站点" width="620px" destroy-on-close>
|
||||
<el-form ref="editFormRef" :model="editForm" :rules="editRules" label-width="130px">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="站点标识">
|
||||
<el-tag size="default" type="info">{{ editForm.site_code }}</el-tag>
|
||||
<span style="margin-left: 8px; font-size: 12px; color: #9ca3af">不可更改</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="分销管理员">
|
||||
<span v-if="editForm.admin_username" style="font-weight: 500">
|
||||
{{ editForm.admin_username }}
|
||||
</span>
|
||||
<span v-else style="color: #9ca3af">未关联</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="站点名称" prop="site_name">
|
||||
<el-input v-model="editForm.site_name" placeholder="请输入分销站点名称" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="分销专属域名" prop="domain">
|
||||
<el-input v-model="editForm.domain" placeholder="请输入绑定的独立域名" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="运行状态" prop="status">
|
||||
<el-radio-group v-model="editForm.status">
|
||||
<el-radio value="active">正常运营</el-radio>
|
||||
<el-radio value="disabled">暂停服务 (已停用)</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="展示主站账号">
|
||||
<el-switch v-model="editForm.display_account_enabled" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="是否征税">
|
||||
<el-switch v-model="editForm.tax_enabled" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16" v-if="editForm.tax_enabled">
|
||||
<el-col :span="24">
|
||||
<el-form-item label="综合税率 (%)">
|
||||
<el-input-number
|
||||
:model-value="editForm.tax_rate_bps / 100"
|
||||
:min="0"
|
||||
:max="50"
|
||||
:step="0.1"
|
||||
:precision="2"
|
||||
@update:model-value="
|
||||
(v: number | undefined) => (editForm.tax_rate_bps = Math.round((v || 0) * 100))
|
||||
"
|
||||
/>
|
||||
<span class="settlement-hint">%(基点折算)</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="结算冷却期 (天)">
|
||||
<el-input-number
|
||||
v-model="editForm.settlement_hold_days"
|
||||
:min="0"
|
||||
:max="365"
|
||||
:step="1"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="收号压价 (点)">
|
||||
<el-input-number
|
||||
v-model="editForm.receive_cost_delta"
|
||||
:min="0"
|
||||
:max="20"
|
||||
:step="0.1"
|
||||
:precision="1"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editSubmitting" @click="handleEditSubmit">
|
||||
保存修改
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 重置分销商管理员密码对话框 -->
|
||||
<el-dialog
|
||||
v-model="resetPwdDialogVisible"
|
||||
title="重置分销商管理员密码"
|
||||
width="460px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="重置后旧密码将立即失效,分销商管理员需使用新密码重新登录独立分销后台。"
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
<el-form label-width="110px">
|
||||
<el-form-item label="分销站点">
|
||||
<el-input :model-value="resetPwdForm.site_name" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="管理员账号">
|
||||
<el-input :model-value="resetPwdForm.admin_username" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="新登录密码" required>
|
||||
<el-input
|
||||
v-model="resetPwdForm.new_password"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请输入至少6位新登录密码"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="resetPwdDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="resetPwdSubmitting" @click="handleResetPasswordSubmit">
|
||||
确认重置密码
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -959,6 +1386,51 @@ onMounted(() => {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filter-stats {
|
||||
margin-left: auto;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.filter-stats strong {
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.domain-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.domain-text {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.admin-nickname {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #9ca3af;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.site-name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user