feat: 客户关联产品并支持按产品圈选通知群体

新增客户-产品多对多关联,列表可按产品筛选并复制邮箱;发证/下单自动回填历史关联。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Logan.LiandCursor committed 2026-08-12 21:58:35 +08:00
1 parent d0e1ca3729
commit 4c46088f4f
10 files changed
+337 -89

No files matched your search

+48 -2
View File
@@ -63,8 +63,9 @@ func migrateDatabase(db *gorm.DB) (*gorm.DB, error) {
&model.PaymentGatewayConfig{}, // 支付网关配置 // Logan.Li &model.PaymentGatewayConfig{}, // 支付网关配置 // Logan.Li
// 核心授权域 // 核心授权域
&model.Product{}, // 产品 // Logan.Li &model.Product{}, // 产品 // Logan.Li
&model.Customer{}, // 客户 // Logan.Li &model.Customer{}, // 客户 // Logan.Li
&model.License{}, // License // Logan.Li &model.CustomerProduct{}, // 客户-产品关联 // Logan.Li
&model.License{}, // License // Logan.Li
&model.Activation{}, // 激活记录 // Logan.Li &model.Activation{}, // 激活记录 // Logan.Li
// 定价与订单 // 定价与订单
&model.Plan{}, // 定价计划 // Logan.Li &model.Plan{}, // 定价计划 // Logan.Li
@@ -81,5 +82,50 @@ func migrateDatabase(db *gorm.DB) (*gorm.DB, error) {
return nil, fmt.Errorf("种子数据初始化失败: %v", err) return nil, fmt.Errorf("种子数据初始化失败: %v", err)
} }
if err := backfillCustomerProducts(db); err != nil {
return nil, fmt.Errorf("客户产品关联回填失败: %v", err)
}
return db, nil return db, nil
} }
// backfillCustomerProducts 用历史 License/权益回填客户-产品关联 // Logan.Li
func backfillCustomerProducts(db *gorm.DB) error {
type pair struct {
CustomerID uint
ProductID uint
}
var pairs []pair
if err := db.Model(&model.License{}).
Select("DISTINCT customer_id, product_id").
Where("customer_id > 0 AND product_id > 0").
Scan(&pairs).Error; err != nil {
return err
}
var entPairs []pair
_ = db.Model(&model.CustomerEntitlement{}).
Select("DISTINCT customer_id, product_id").
Where("customer_id > 0 AND product_id > 0").
Scan(&entPairs)
pairs = append(pairs, entPairs...)
seen := map[[2]uint]struct{}{}
for _, p := range pairs {
k := [2]uint{p.CustomerID, p.ProductID}
if _, ok := seen[k]; ok {
continue
}
seen[k] = struct{}{}
var n int64
db.Model(&model.CustomerProduct{}).
Where("customer_id = ? AND product_id = ?", p.CustomerID, p.ProductID).
Count(&n)
if n > 0 {
continue
}
if err := db.Create(&model.CustomerProduct{CustomerID: p.CustomerID, ProductID: p.ProductID}).Error; err != nil {
return err
}
}
return nil
}
@@ -1,6 +1,7 @@
package customer package customer
import ( import (
"backend/internal/dto"
customerservice "backend/internal/service/customer" customerservice "backend/internal/service/customer"
"backend/utils" "backend/utils"
"net/http" "net/http"
@@ -21,13 +22,14 @@ func NewCustomerController(db *gorm.DB) *CustomerController {
return &CustomerController{svc: customerservice.NewCustomerService(db), db: db} return &CustomerController{svc: customerservice.NewCustomerService(db), db: db}
} }
// List 客户列表 // Logan.Li // List 客户列表(支持 product_id 筛选通知群体) // Logan.Li
func (ctrl *CustomerController) List(c *gin.Context) { func (ctrl *CustomerController) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
keyword := c.Query("keyword") keyword := c.Query("keyword")
productID, _ := strconv.ParseUint(c.Query("product_id"), 10, 32)
customers, total, err := ctrl.svc.List(page, pageSize, keyword) customers, total, err := ctrl.svc.List(page, pageSize, keyword, uint(productID))
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()})
return return
@@ -38,9 +40,10 @@ func (ctrl *CustomerController) List(c *gin.Context) {
}}) }})
} }
// GetAll 全部客户 // Logan.Li // GetAll 全部客户(可选 product_id) // Logan.Li
func (ctrl *CustomerController) GetAll(c *gin.Context) { func (ctrl *CustomerController) GetAll(c *gin.Context) {
customers, err := ctrl.svc.GetAll() productID, _ := strconv.ParseUint(c.Query("product_id"), 10, 32)
customers, err := ctrl.svc.GetAll(uint(productID))
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()})
return return
@@ -61,15 +64,7 @@ func (ctrl *CustomerController) Get(c *gin.Context) {
// Create 创建客户 // Logan.Li // Create 创建客户 // Logan.Li
func (ctrl *CustomerController) Create(c *gin.Context) { func (ctrl *CustomerController) Create(c *gin.Context) {
var req struct { var req dto.CustomerCreate
Name string `json:"name" binding:"required"`
Company string `json:"company"`
Email string `json:"email" binding:"omitempty,email"`
Phone string `json:"phone"`
Address string `json:"address"`
Notes string `json:"notes"`
Status *int `json:"status"`
}
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误: " + err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误: " + err.Error()})
return return
@@ -86,15 +81,7 @@ func (ctrl *CustomerController) Create(c *gin.Context) {
// Update 更新客户 // Logan.Li // Update 更新客户 // Logan.Li
func (ctrl *CustomerController) Update(c *gin.Context) { func (ctrl *CustomerController) Update(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 32) id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
var req struct { var req dto.CustomerUpdate
Name string `json:"name"`
Company string `json:"company"`
Email string `json:"email" binding:"omitempty,email"`
Phone string `json:"phone"`
Address string `json:"address"`
Notes string `json:"notes"`
Status *int `json:"status"`
}
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
return return
+36 -25
View File
@@ -40,36 +40,47 @@ type ProductResponse struct {
// CustomerCreate 创建客户请求 // Logan.Li // CustomerCreate 创建客户请求 // Logan.Li
type CustomerCreate struct { type CustomerCreate struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
Company string `json:"company"` Company string `json:"company"`
Email string `json:"email" binding:"omitempty,email"` Email string `json:"email" binding:"omitempty,email"`
Phone string `json:"phone"` Phone string `json:"phone"`
Address string `json:"address"` Address string `json:"address"`
Notes string `json:"notes"` Notes string `json:"notes"`
Status *int `json:"status"` Status *int `json:"status"`
ProductIDs []uint `json:"product_ids"` // 关联产品,便于按产品圈选通知 // Logan.Li
} }
// CustomerUpdate 更新客户请求 // Logan.Li // CustomerUpdate 更新客户请求(ProductIDs 用指针区分未传与清空) // Logan.Li
type CustomerUpdate struct { type CustomerUpdate struct {
Name string `json:"name"` Name string `json:"name"`
Company string `json:"company"` Company string `json:"company"`
Email string `json:"email" binding:"omitempty,email"` Email string `json:"email" binding:"omitempty,email"`
Phone string `json:"phone"` Phone string `json:"phone"`
Address string `json:"address"` Address string `json:"address"`
Notes string `json:"notes"` Notes string `json:"notes"`
Status *int `json:"status"` Status *int `json:"status"`
ProductIDs *[]uint `json:"product_ids"`
}
// CustomerProductBrief 客户关联产品简要信息 // Logan.Li
type CustomerProductBrief struct {
ID uint `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
} }
// CustomerResponse 客户响应 // Logan.Li // CustomerResponse 客户响应 // Logan.Li
type CustomerResponse struct { type CustomerResponse struct {
ID uint `json:"id"` ID uint `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Company string `json:"company"` Company string `json:"company"`
Email string `json:"email"` Email string `json:"email"`
Phone string `json:"phone"` Phone string `json:"phone"`
Address string `json:"address"` Address string `json:"address"`
Notes string `json:"notes"` Notes string `json:"notes"`
Status int `json:"status"` Status int `json:"status"`
CreatedAt string `json:"created_at"` ProductIDs []uint `json:"product_ids"`
UpdatedAt string `json:"updated_at"` Products []CustomerProductBrief `json:"products"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
} }
+1
View File
@@ -17,6 +17,7 @@ type Customer struct {
Notes string `json:"notes" gorm:"type:text"` Notes string `json:"notes" gorm:"type:text"`
Status int `json:"status" gorm:"default:1"` // 1: 启用, 0: 禁用 Status int `json:"status" gorm:"default:1"` // 1: 启用, 0: 禁用
Licenses []License `json:"licenses,omitempty" gorm:"foreignKey:CustomerID"` Licenses []License `json:"licenses,omitempty" gorm:"foreignKey:CustomerID"`
Products []Product `json:"products,omitempty" gorm:"many2many:customer_products;"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"deleted_at,omitempty" gorm:"index"` DeletedAt gorm.DeletedAt `json:"deleted_at,omitempty" gorm:"index"`
@@ -0,0 +1,15 @@
package model
import "time"
// CustomerProduct 客户与产品关联(用于按产品圈选通知群体) // Logan.Li
type CustomerProduct struct {
CustomerID uint `json:"customer_id" gorm:"primaryKey;autoIncrement:false"`
ProductID uint `json:"product_id" gorm:"primaryKey;autoIncrement:false"`
CreatedAt time.Time `json:"created_at"`
}
// TableName 指定表名 // Logan.Li
func (CustomerProduct) TableName() string {
return "customer_products"
}
@@ -18,15 +18,19 @@ func NewCustomerService(db *gorm.DB) *CustomerService {
return &CustomerService{db: db} return &CustomerService{db: db}
} }
// List 分页查询客户 // Logan.Li // List 分页查询客户(可按产品筛选通知群体) // Logan.Li
func (s *CustomerService) List(page, pageSize int, keyword string) ([]dto.CustomerResponse, int64, error) { func (s *CustomerService) List(page, pageSize int, keyword string, productID uint) ([]dto.CustomerResponse, int64, error) {
var customers []model.Customer var customers []model.Customer
var total int64 var total int64
query := s.db.Model(&model.Customer{}) query := s.db.Model(&model.Customer{}).Preload("Products")
if keyword != "" { if keyword != "" {
query = query.Where("name LIKE ? OR email LIKE ? OR company LIKE ?", "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") query = query.Where("name LIKE ? OR email LIKE ? OR company LIKE ?", "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%")
} }
if productID > 0 {
sub := s.db.Table("customer_products").Select("customer_id").Where("product_id = ?", productID)
query = query.Where("id IN (?)", sub)
}
query.Count(&total) query.Count(&total)
if err := query.Order("created_at DESC"). if err := query.Order("created_at DESC").
@@ -42,10 +46,15 @@ func (s *CustomerService) List(page, pageSize int, keyword string) ([]dto.Custom
return results, total, nil return results, total, nil
} }
// GetAll 获取全部客户(下拉选项用) // Logan.Li // GetAll 获取全部启用客户(下拉;可按产品过滤) // Logan.Li
func (s *CustomerService) GetAll() ([]dto.CustomerResponse, error) { func (s *CustomerService) GetAll(productID uint) ([]dto.CustomerResponse, error) {
var customers []model.Customer var customers []model.Customer
if err := s.db.Where("status = ?", 1).Order("created_at DESC").Find(&customers).Error; err != nil { query := s.db.Where("status = ?", 1).Preload("Products")
if productID > 0 {
sub := s.db.Table("customer_products").Select("customer_id").Where("product_id = ?", productID)
query = query.Where("id IN (?)", sub)
}
if err := query.Order("created_at DESC").Find(&customers).Error; err != nil {
return nil, err return nil, err
} }
results := make([]dto.CustomerResponse, 0, len(customers)) results := make([]dto.CustomerResponse, 0, len(customers))
@@ -58,14 +67,14 @@ func (s *CustomerService) GetAll() ([]dto.CustomerResponse, error) {
// GetByID 根据 ID 获取客户 // Logan.Li // GetByID 根据 ID 获取客户 // Logan.Li
func (s *CustomerService) GetByID(id uint) (*dto.CustomerResponse, error) { func (s *CustomerService) GetByID(id uint) (*dto.CustomerResponse, error) {
var customer model.Customer var customer model.Customer
if err := s.db.First(&customer, id).Error; err != nil { if err := s.db.Preload("Products").First(&customer, id).Error; err != nil {
return nil, errors.New("客户不存在") return nil, errors.New("客户不存在")
} }
resp := toCustomerResponse(customer) resp := toCustomerResponse(customer)
return &resp, nil return &resp, nil
} }
// Create 创建客户 // Logan.Li // Create 创建客户并关联产品 // Logan.Li
func (s *CustomerService) Create(req dto.CustomerCreate) (*dto.CustomerResponse, error) { func (s *CustomerService) Create(req dto.CustomerCreate) (*dto.CustomerResponse, error) {
status := 1 status := 1
if req.Status != nil { if req.Status != nil {
@@ -83,11 +92,13 @@ func (s *CustomerService) Create(req dto.CustomerCreate) (*dto.CustomerResponse,
if err := s.db.Create(&customer).Error; err != nil { if err := s.db.Create(&customer).Error; err != nil {
return nil, errors.New("创建客户失败") return nil, errors.New("创建客户失败")
} }
resp := toCustomerResponse(customer) if err := s.replaceProducts(customer.ID, req.ProductIDs); err != nil {
return &resp, nil return nil, err
}
return s.GetByID(customer.ID)
} }
// Update 更新客户 // Logan.Li // Update 更新客户及产品关联 // Logan.Li
func (s *CustomerService) Update(id uint, req dto.CustomerUpdate) (*dto.CustomerResponse, error) { func (s *CustomerService) Update(id uint, req dto.CustomerUpdate) (*dto.CustomerResponse, error) {
var customer model.Customer var customer model.Customer
if err := s.db.First(&customer, id).Error; err != nil { if err := s.db.First(&customer, id).Error; err != nil {
@@ -109,9 +120,13 @@ func (s *CustomerService) Update(id uint, req dto.CustomerUpdate) (*dto.Customer
if err := s.db.Model(&customer).Updates(updates).Error; err != nil { if err := s.db.Model(&customer).Updates(updates).Error; err != nil {
return nil, errors.New("更新客户失败") return nil, errors.New("更新客户失败")
} }
s.db.First(&customer, id) // ProductIDs 为 nil 时不改关联;非 nil(含空切片)则全量替换 // Logan.Li
resp := toCustomerResponse(customer) if req.ProductIDs != nil {
return &resp, nil if err := s.replaceProducts(id, *req.ProductIDs); err != nil {
return nil, err
}
}
return s.GetByID(id)
} }
// Delete 删除客户(软删除) // Logan.Li // Delete 删除客户(软删除) // Logan.Li
@@ -121,6 +136,7 @@ func (s *CustomerService) Delete(id uint) error {
if count > 0 { if count > 0 {
return errors.New("该客户下存在 License,无法删除") return errors.New("该客户下存在 License,无法删除")
} }
_ = s.db.Where("customer_id = ?", id).Delete(&model.CustomerProduct{}).Error
return s.db.Delete(&model.Customer{}, id).Error return s.db.Delete(&model.Customer{}, id).Error
} }
@@ -129,18 +145,66 @@ func (s *CustomerService) UpdateStatus(id uint, status int) error {
return s.db.Model(&model.Customer{}).Where("id = ?", id).Update("status", status).Error return s.db.Model(&model.Customer{}).Where("id = ?", id).Update("status", status).Error
} }
// AttachProduct 为客户追加产品关联(发证/下单时调用,幂等) // Logan.Li
func AttachProduct(db *gorm.DB, customerID, productID uint) error {
if db == nil || customerID == 0 || productID == 0 {
return nil
}
var n int64
db.Model(&model.CustomerProduct{}).
Where("customer_id = ? AND product_id = ?", customerID, productID).
Count(&n)
if n > 0 {
return nil
}
return db.Create(&model.CustomerProduct{CustomerID: customerID, ProductID: productID}).Error
}
// replaceProducts 全量替换客户产品关联 // Logan.Li
func (s *CustomerService) replaceProducts(customerID uint, productIDs []uint) error {
if err := s.db.Where("customer_id = ?", customerID).Delete(&model.CustomerProduct{}).Error; err != nil {
return errors.New("更新产品关联失败")
}
seen := map[uint]struct{}{}
for _, pid := range productIDs {
if pid == 0 {
continue
}
if _, ok := seen[pid]; ok {
continue
}
seen[pid] = struct{}{}
var product model.Product
if err := s.db.First(&product, pid).Error; err != nil {
return errors.New("关联产品不存在")
}
if err := s.db.Create(&model.CustomerProduct{CustomerID: customerID, ProductID: pid}).Error; err != nil {
return errors.New("更新产品关联失败")
}
}
return nil
}
// toCustomerResponse 模型转响应 DTO // Logan.Li // toCustomerResponse 模型转响应 DTO // Logan.Li
func toCustomerResponse(c model.Customer) dto.CustomerResponse { func toCustomerResponse(c model.Customer) dto.CustomerResponse {
products := make([]dto.CustomerProductBrief, 0, len(c.Products))
ids := make([]uint, 0, len(c.Products))
for _, p := range c.Products {
products = append(products, dto.CustomerProductBrief{ID: p.ID, Name: p.Name, Code: p.Code})
ids = append(ids, p.ID)
}
return dto.CustomerResponse{ return dto.CustomerResponse{
ID: c.ID, ID: c.ID,
Name: c.Name, Name: c.Name,
Company: c.Company, Company: c.Company,
Email: c.Email, Email: c.Email,
Phone: c.Phone, Phone: c.Phone,
Address: c.Address, Address: c.Address,
Notes: c.Notes, Notes: c.Notes,
Status: c.Status, Status: c.Status,
CreatedAt: c.CreatedAt.Format("2006-01-02 15:04:05"), ProductIDs: ids,
UpdatedAt: c.UpdatedAt.Format("2006-01-02 15:04:05"), Products: products,
CreatedAt: c.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: c.UpdatedAt.Format("2006-01-02 15:04:05"),
} }
} }
@@ -3,6 +3,7 @@ package license
import ( import (
"backend/internal/dto" "backend/internal/dto"
"backend/internal/model" "backend/internal/model"
customersvc "backend/internal/service/customer"
"backend/utils" "backend/utils"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -137,6 +138,7 @@ func (s *LicenseService) Create(req dto.LicenseCreate) ([]dto.LicenseResponse, e
if err := s.db.Create(&licenses).Error; err != nil { if err := s.db.Create(&licenses).Error; err != nil {
return nil, errors.New("创建 License 失败") return nil, errors.New("创建 License 失败")
} }
_ = customersvc.AttachProduct(s.db, req.CustomerID, req.ProductID)
results := make([]dto.LicenseResponse, 0, len(licenses)) results := make([]dto.LicenseResponse, 0, len(licenses))
for _, l := range licenses { for _, l := range licenses {
@@ -4,6 +4,7 @@ import (
"backend/config" "backend/config"
"backend/internal/dto" "backend/internal/dto"
"backend/internal/model" "backend/internal/model"
customersvc "backend/internal/service/customer"
"backend/utils" "backend/utils"
"errors" "errors"
"fmt" "fmt"
@@ -139,6 +140,7 @@ func (s *OfflineIssuer) Issue(req dto.OfflineLicenseCreate) (*dto.OfflineLicense
if err := s.db.Create(&lic).Error; err != nil { if err := s.db.Create(&lic).Error; err != nil {
return nil, errors.New("保存离线 License 失败") return nil, errors.New("保存离线 License 失败")
} }
_ = customersvc.AttachProduct(s.db, customer.ID, product.ID)
activation := model.Activation{ activation := model.Activation{
LicenseID: lic.ID, LicenseID: lic.ID,
@@ -3,6 +3,7 @@ package order
import ( import (
"backend/internal/dto" "backend/internal/dto"
"backend/internal/model" "backend/internal/model"
customersvc "backend/internal/service/customer"
"backend/internal/service/payment" "backend/internal/service/payment"
"backend/utils" "backend/utils"
"errors" "errors"
@@ -142,6 +143,7 @@ func (s *OrderService) CreateOrder(req dto.CreateOrderRequest, clientIP, userAge
if err := s.db.Create(&order).Error; err != nil { if err := s.db.Create(&order).Error; err != nil {
return nil, errors.New("创建订单失败") return nil, errors.New("创建订单失败")
} }
_ = customersvc.AttachProduct(s.db, customer.ID, product.ID)
// 6. 生成收银台地址 // Logan.Li // 6. 生成收银台地址 // Logan.Li
var checkoutURL string var checkoutURL string
@@ -365,6 +367,7 @@ func (s *OrderService) PaymentWebhook(req dto.PaymentWebhookRequest, clientIP st
if err := tx.Create(&lic).Error; err != nil { if err := tx.Create(&lic).Error; err != nil {
return err return err
} }
_ = customersvc.AttachProduct(tx, order.CustomerID, order.ProductID)
if err := tx.Model(&model.Order{}).Where("id = ?", order.ID). if err := tx.Model(&model.Order{}).Where("id = ?", order.ID).
Update("license_id", lic.ID).Error; err != nil { Update("license_id", lic.ID).Error; err != nil {
return err return err
@@ -471,6 +474,7 @@ func (s *OrderService) ManualGrant(req dto.ManualGrantRequest, operatorID uint,
if err := s.db.Create(&lic).Error; err != nil { if err := s.db.Create(&lic).Error; err != nil {
return nil, errors.New("创建 License 失败") return nil, errors.New("创建 License 失败")
} }
_ = customersvc.AttachProduct(s.db, customer.ID, req.ProductID)
// 创建/更新权益记录 // Logan.Li // 创建/更新权益记录 // Logan.Li
renewable := plan.PlanType != "perpetual" && plan.PlanType != "lifetime" renewable := plan.PlanType != "perpetual" && plan.PlanType != "lifetime"
+132 -16
View File
@@ -2,14 +2,57 @@
<div> <div>
<el-card shadow="never"> <el-card shadow="never">
<div class="toolbar"> <div class="toolbar">
<el-input v-model="keyword" placeholder="搜索客户/邮箱/公司" clearable style="width: 260px" @keyup.enter="loadData" @clear="loadData" /> <div class="filters">
<el-button type="primary" @click="openCreate">新增客户</el-button> <el-input
v-model="keyword"
placeholder="搜索客户/邮箱/公司"
clearable
style="width: 240px"
@keyup.enter="search"
@clear="search"
/>
<el-select
v-model="productFilter"
placeholder="按产品筛选"
clearable
filterable
style="width: 180px"
@change="search"
>
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</div>
<div class="actions">
<el-button :disabled="!productFilter || list.length === 0" @click="copyEmails">复制本页邮箱</el-button>
<el-button type="primary" @click="openCreate">新增客户</el-button>
</div>
</div> </div>
<el-alert
type="info"
:closable="false"
show-icon
title="客户可关联多个产品。按产品筛选后即可圈选通知群体;发证/购买会自动写入产品关联。"
style="margin-bottom: 12px"
/>
<el-table :data="list" v-loading="loading" stripe> <el-table :data="list" v-loading="loading" stripe>
<el-table-column prop="name" label="客户名称" min-width="120" /> <el-table-column prop="name" label="客户名称" min-width="120" />
<el-table-column prop="company" label="公司" min-width="140" /> <el-table-column prop="company" label="公司" min-width="120" />
<el-table-column prop="email" label="邮箱" min-width="180" /> <el-table-column prop="email" label="邮箱" min-width="180" />
<el-table-column label="关联产品" min-width="200">
<template #default="{ row }">
<template v-if="row.products?.length">
<el-tag
v-for="p in row.products"
:key="p.id"
size="small"
style="margin: 2px 4px 2px 0"
>{{ p.name }}</el-tag>
</template>
<span v-else class="muted">未关联</span>
</template>
</el-table-column>
<el-table-column prop="phone" label="电话" width="130" /> <el-table-column prop="phone" label="电话" width="130" />
<el-table-column label="状态" width="80"> <el-table-column label="状态" width="80">
<template #default="{ row }"> <template #default="{ row }">
@@ -32,12 +75,24 @@
/> />
</el-card> </el-card>
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑客户' : '新增客户'" width="520px"> <el-dialog v-model="dialogVisible" :title="form.id ? '编辑客户' : '新增客户'" width="560px" @closed="onDialogClosed">
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px"> <el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="名称" prop="name"><el-input v-model="form.name" /></el-form-item> <el-form-item label="名称" prop="name"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="公司"><el-input v-model="form.company" /></el-form-item> <el-form-item label="公司"><el-input v-model="form.company" /></el-form-item>
<el-form-item label="邮箱" prop="email"><el-input v-model="form.email" /></el-form-item> <el-form-item label="邮箱" prop="email"><el-input v-model="form.email" /></el-form-item>
<el-form-item label="电话"><el-input v-model="form.phone" /></el-form-item> <el-form-item label="电话"><el-input v-model="form.phone" /></el-form-item>
<el-form-item label="关联产品" prop="product_ids">
<el-select
v-model="form.product_ids"
multiple
filterable
clearable
placeholder="选择产品(可多选)"
style="width: 100%"
>
<el-option v-for="p in products" :key="p.id" :label="`${p.name} (${p.code})`" :value="p.id" />
</el-select>
</el-form-item>
<el-form-item label="地址"><el-input v-model="form.address" /></el-form-item> <el-form-item label="地址"><el-input v-model="form.address" /></el-form-item>
<el-form-item label="备注"><el-input v-model="form.notes" type="textarea" :rows="2" /></el-form-item> <el-form-item label="备注"><el-input v-model="form.notes" type="textarea" :rows="2" /></el-form-item>
</el-form> </el-form>
@@ -50,50 +105,91 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { getCustomers, createCustomer, updateCustomer, deleteCustomer, updateCustomerStatus } from '@/api/customer' import { getCustomers, createCustomer, updateCustomer, deleteCustomer, updateCustomerStatus } from '@/api/customer'
import { getAllProducts } from '@/api/product'
const list = ref([]) const list = ref([])
const products = ref([])
const loading = ref(false) const loading = ref(false)
const keyword = ref('') const keyword = ref('')
const productFilter = ref(null)
const page = ref(1) const page = ref(1)
const pageSize = ref(10) const pageSize = ref(10)
const total = ref(0) const total = ref(0)
const dialogVisible = ref(false) const dialogVisible = ref(false)
const formRef = ref(null) const formRef = ref(null)
const form = reactive({ id: null, name: '', company: '', email: '', phone: '', address: '', notes: '' }) const form = reactive({
id: null, name: '', company: '', email: '', phone: '', address: '', notes: '', product_ids: [],
})
const rules = { const rules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }], name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
email: [{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }], email: [{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }],
} }
function search() {
page.value = 1
loadData()
}
async function loadData() { async function loadData() {
loading.value = true loading.value = true
try { try {
const res = await getCustomers({ page: page.value, page_size: pageSize.value, keyword: keyword.value }) const res = await getCustomers({
page: page.value,
page_size: pageSize.value,
keyword: keyword.value,
product_id: productFilter.value || undefined,
})
list.value = res.data.list || [] list.value = res.data.list || []
total.value = res.data.total total.value = res.data.total
} finally { loading.value = false } } finally {
loading.value = false
}
} }
function openCreate() { function openCreate() {
Object.assign(form, { id: null, name: '', company: '', email: '', phone: '', address: '', notes: '' }) Object.assign(form, {
id: null, name: '', company: '', email: '', phone: '', address: '', notes: '', product_ids: [],
})
dialogVisible.value = true dialogVisible.value = true
} }
function edit(row) { function edit(row) {
Object.assign(form, row) Object.assign(form, {
id: row.id,
name: row.name,
company: row.company,
email: row.email,
phone: row.phone,
address: row.address,
notes: row.notes,
product_ids: [...(row.product_ids || [])],
})
dialogVisible.value = true dialogVisible.value = true
} }
function onDialogClosed() {
formRef.value?.clearValidate?.()
}
async function submit() { async function submit() {
await formRef.value.validate(async (valid) => { await formRef.value.validate(async (valid) => {
if (!valid) return if (!valid) return
try { try {
if (form.id) await updateCustomer(form.id, form) const payload = {
else await createCustomer(form) name: form.name,
company: form.company,
email: form.email,
phone: form.phone,
address: form.address,
notes: form.notes,
product_ids: form.product_ids || [],
}
if (form.id) await updateCustomer(form.id, payload)
else await createCustomer(payload)
dialogVisible.value = false dialogVisible.value = false
loadData() loadData()
} catch { /* */ } } catch { /* */ }
@@ -111,9 +207,29 @@ async function toggleStatus(row, val) {
loadData() loadData()
} }
loadData() async function copyEmails() {
const emails = list.value.map((r) => (r.email || '').trim()).filter(Boolean)
if (!emails.length) {
ElMessage.warning('本页没有可复制的邮箱')
return
}
try {
await navigator.clipboard.writeText(emails.join(', '))
ElMessage.success(`已复制 ${emails.length} 个邮箱`)
} catch {
ElMessage.error('复制失败,请手动选择')
}
}
onMounted(async () => {
const res = await getAllProducts()
products.value = res.data || []
loadData()
})
</script> </script>
<style scoped> <style scoped>
.toolbar { display: flex; justify-content: space-between; margin-bottom: 16px; } .toolbar { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.filters, .actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.muted { color: #c0c4cc; font-size: 13px; }
</style> </style>