diff --git a/backend/internal/modules/distribution/dto.go b/backend/internal/modules/distribution/dto.go index ef64a3b..a8922eb 100644 --- a/backend/internal/modules/distribution/dto.go +++ b/backend/internal/modules/distribution/dto.go @@ -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"` diff --git a/backend/internal/modules/distribution/handler.go b/backend/internal/modules/distribution/handler.go index 1ebb495..54fd1b2 100644 --- a/backend/internal/modules/distribution/handler.go +++ b/backend/internal/modules/distribution/handler.go @@ -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 { diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index 57fab80..5848e1a 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -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") diff --git a/backend/internal/modules/distribution/service.go b/backend/internal/modules/distribution/service.go index 6035dc5..5183881 100644 --- a/backend/internal/modules/distribution/service.go +++ b/backend/internal/modules/distribution/service.go @@ -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 diff --git a/backend/internal/modules/distribution/site_crud_test.go b/backend/internal/modules/distribution/site_crud_test.go new file mode 100644 index 0000000..8eedba3 --- /dev/null +++ b/backend/internal/modules/distribution/site_crud_test.go @@ -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) + } +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 6fed9ec..6c2518d 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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) diff --git a/distribution-admin/src/api.ts b/distribution-admin/src/api.ts index 4e1b591..d1a7847 100644 --- a/distribution-admin/src/api.ts +++ b/distribution-admin/src/api.ts @@ -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 { export async function updateSite( siteID: number, - payload: Partial> + 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 { - const resp = await api.put>(`/distribution/sites/${siteID}`, payload) + const resp = await api.put>(`/admin/distribution/sites/${siteID}`, payload) + return extract(resp) +} + +export async function deleteSite(siteID: number): Promise<{ deleted: boolean }> { + const resp = await api.delete>(`/admin/distribution/sites/${siteID}`) + return extract(resp) +} + +export async function resetSiteAdminPassword(siteID: number, newPassword: string): Promise<{ message: string }> { + const resp = await api.put>(`/admin/distribution/sites/${siteID}/admin-password`, { + new_password: newPassword, + }) return extract(resp) } diff --git a/distribution-admin/src/pages/Sites.tsx b/distribution-admin/src/pages/Sites.tsx index c8d732a..8f1080c 100644 --- a/distribution-admin/src/pages/Sites.tsx +++ b/distribution-admin/src/pages/Sites.tsx @@ -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([]) @@ -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('') + + // 编辑弹窗 + const [editModalOpen, setEditModalOpen] = useState(false) + const [editSubmitting, setEditSubmitting] = useState(false) + const [editingSite, setEditingSite] = useState(null) + const [editForm] = Form.useForm() + + // 重置密码弹窗 + const [resetPwdModalOpen, setResetPwdModalOpen] = useState(false) + const [resetPwdSubmitting, setResetPwdSubmitting] = useState(false) + const [resetPwdSite, setResetPwdSite] = useState(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 (
@@ -99,28 +240,65 @@ export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean 分销网站管理 - 管理全平台合作分销站点、域名配置与分销商管理员账号 + 管理全平台合作分销站点、域名配置、结算规则与分销商管理员账号
{isSuperadmin && ( )} + {/* 筛选检索区 */} +
+ } + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + allowClear + style={{ width: 280 }} + /> + + + + + + + + + + 正常运营 + 暂停服务 (已停用) + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ + + + {/* 重置管理员密码 Modal */} + setResetPwdModalOpen(false)} + footer={null} + width={450} + destroyOnClose + > +
+ + {resetPwdSite?.site_name} + + {resetPwdSite?.admin_username || '专属管理员'} + + + + + + + +
+ + +
+
+
+ {/* 开通分销商 Modal */} - + - + 分销运营 (operator) + 分销财务 (finance) + 分销客服 (customer_service) + - 3. 结算冷却期与展示 - - 开通后四种分成模式均按默认生效:收号 35% / 卖出 40% / 服务费 25%(本站自卖 75% + 25%)。之后可在规则页调整。 + 3. 结算冷却期与展示配置 + + 开通后四种模式均按默认生效:收号 35% / 卖出 40% / 服务费 25%(自卖为 75% + 25%)。之后可在「分销规则」页单独调整。 + + + + - + - - - + + + - - + prev.tax_enabled !== curr.tax_enabled} + > + {({ getFieldValue }) => + getFieldValue('tax_enabled') ? ( + + + + ) : null + } diff --git a/frontend/src/features/admin/api/adminDistribution.ts b/frontend/src/features/admin/api/adminDistribution.ts index c1664f7..5394683 100644 --- a/frontend/src/features/admin/api/adminDistribution.ts +++ b/frontend/src/features/admin/api/adminDistribution.ts @@ -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>( + `/admin/distribution/sites/${id}` + ) + return data.data +} + +export async function resetDistributionSiteAdminPassword(id: number, newPassword: string) { + const { data } = await apiClient.put>( + `/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>( '/admin/distribution/rules', diff --git a/frontend/src/features/admin/views/AdminDistributionSitesView.vue b/frontend/src/features/admin/views/AdminDistributionSitesView.vue index c359b75..3b44402 100644 --- a/frontend/src/features/admin/views/AdminDistributionSitesView.vue +++ b/frontend/src/features/admin/views/AdminDistributionSitesView.vue @@ -1,5 +1,5 @@