feat(distribution): 分销站点管理支持完整 CRUD、安全删除保护与管理员密码重置

- 后端支持分销站点编辑(域名唯一性校验、收号压价点位及税率更新)
- 后端支持分销站点安全删除校验(阻断存在业务订单、财务流水、结算批次或钱包余额的站点删除;对干净站点连带清理分成规则、展示账号绑定、支付通道配置及专有站长账号)
- 后端支持站长账号展示与超级管理员重置站长密码
- 主管理后台与分销后台前端全面支持筛选搜索、编辑模态窗、状态快捷切换、站长密码重置与安全删除确认
- 补充后端 CRUD、删除保护与密码重置覆盖率测试用例
This commit is contained in:
yml2213
2026-09-17 19:35:52 +08:00
parent 079e18bd3a
commit 1c3a75eef0
10 changed files with 1452 additions and 72 deletions
@@ -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)
}
}
+3
View File
@@ -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)
+32 -2
View File
@@ -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)
}
+422 -45
View File
@@ -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;