feat: 客户关联产品并支持按产品圈选通知群体
新增客户-产品多对多关联,列表可按产品筛选并复制邮箱;发证/下单自动回填历史关联。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
d0e1ca3729
commit
4c46088f4f
10 files changed
+337
-89
No files matched your search
@@ -63,8 +63,9 @@ func migrateDatabase(db *gorm.DB) (*gorm.DB, error) {
|
||||
&model.PaymentGatewayConfig{}, // 支付网关配置 // Logan.Li
|
||||
// 核心授权域
|
||||
&model.Product{}, // 产品 // Logan.Li
|
||||
&model.Customer{}, // 客户 // Logan.Li
|
||||
&model.License{}, // License // Logan.Li
|
||||
&model.Customer{}, // 客户 // Logan.Li
|
||||
&model.CustomerProduct{}, // 客户-产品关联 // Logan.Li
|
||||
&model.License{}, // License // Logan.Li
|
||||
&model.Activation{}, // 激活记录 // Logan.Li
|
||||
// 定价与订单
|
||||
&model.Plan{}, // 定价计划 // Logan.Li
|
||||
@@ -81,5 +82,50 @@ func migrateDatabase(db *gorm.DB) (*gorm.DB, error) {
|
||||
return nil, fmt.Errorf("种子数据初始化失败: %v", err)
|
||||
}
|
||||
|
||||
if err := backfillCustomerProducts(db); err != nil {
|
||||
return nil, fmt.Errorf("客户产品关联回填失败: %v", err)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
import (
|
||||
"backend/internal/dto"
|
||||
customerservice "backend/internal/service/customer"
|
||||
"backend/utils"
|
||||
"net/http"
|
||||
@@ -21,13 +22,14 @@ func NewCustomerController(db *gorm.DB) *CustomerController {
|
||||
return &CustomerController{svc: customerservice.NewCustomerService(db), db: db}
|
||||
}
|
||||
|
||||
// List 客户列表 // Logan.Li
|
||||
// List 客户列表(支持 product_id 筛选通知群体) // Logan.Li
|
||||
func (ctrl *CustomerController) List(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
|
||||
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 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()})
|
||||
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) {
|
||||
customers, err := ctrl.svc.GetAll()
|
||||
productID, _ := strconv.ParseUint(c.Query("product_id"), 10, 32)
|
||||
customers, err := ctrl.svc.GetAll(uint(productID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()})
|
||||
return
|
||||
@@ -61,15 +64,7 @@ func (ctrl *CustomerController) Get(c *gin.Context) {
|
||||
|
||||
// Create 创建客户 // Logan.Li
|
||||
func (ctrl *CustomerController) Create(c *gin.Context) {
|
||||
var req struct {
|
||||
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"`
|
||||
}
|
||||
var req dto.CustomerCreate
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误: " + err.Error()})
|
||||
return
|
||||
@@ -86,15 +81,7 @@ func (ctrl *CustomerController) Create(c *gin.Context) {
|
||||
// Update 更新客户 // Logan.Li
|
||||
func (ctrl *CustomerController) Update(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
var req struct {
|
||||
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"`
|
||||
}
|
||||
var req dto.CustomerUpdate
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||
return
|
||||
|
||||
@@ -40,36 +40,47 @@ type ProductResponse struct {
|
||||
|
||||
// CustomerCreate 创建客户请求 // Logan.Li
|
||||
type CustomerCreate struct {
|
||||
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"`
|
||||
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"`
|
||||
ProductIDs []uint `json:"product_ids"` // 关联产品,便于按产品圈选通知 // Logan.Li
|
||||
}
|
||||
|
||||
// CustomerUpdate 更新客户请求 // Logan.Li
|
||||
// CustomerUpdate 更新客户请求(ProductIDs 用指针区分未传与清空) // Logan.Li
|
||||
type CustomerUpdate struct {
|
||||
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"`
|
||||
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"`
|
||||
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
|
||||
type CustomerResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Company string `json:"company"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Address string `json:"address"`
|
||||
Notes string `json:"notes"`
|
||||
Status int `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Company string `json:"company"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Address string `json:"address"`
|
||||
Notes string `json:"notes"`
|
||||
Status int `json:"status"`
|
||||
ProductIDs []uint `json:"product_ids"`
|
||||
Products []CustomerProductBrief `json:"products"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
@@ -17,6 +17,7 @@ type Customer struct {
|
||||
Notes string `json:"notes" gorm:"type:text"`
|
||||
Status int `json:"status" gorm:"default:1"` // 1: 启用, 0: 禁用
|
||||
Licenses []License `json:"licenses,omitempty" gorm:"foreignKey:CustomerID"`
|
||||
Products []Product `json:"products,omitempty" gorm:"many2many:customer_products;"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
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}
|
||||
}
|
||||
|
||||
// List 分页查询客户 // Logan.Li
|
||||
func (s *CustomerService) List(page, pageSize int, keyword string) ([]dto.CustomerResponse, int64, error) {
|
||||
// List 分页查询客户(可按产品筛选通知群体) // Logan.Li
|
||||
func (s *CustomerService) List(page, pageSize int, keyword string, productID uint) ([]dto.CustomerResponse, int64, error) {
|
||||
var customers []model.Customer
|
||||
var total int64
|
||||
|
||||
query := s.db.Model(&model.Customer{})
|
||||
query := s.db.Model(&model.Customer{}).Preload("Products")
|
||||
if 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)
|
||||
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
|
||||
}
|
||||
|
||||
// GetAll 获取全部客户(下拉选项用) // Logan.Li
|
||||
func (s *CustomerService) GetAll() ([]dto.CustomerResponse, error) {
|
||||
// GetAll 获取全部启用客户(下拉;可按产品过滤) // Logan.Li
|
||||
func (s *CustomerService) GetAll(productID uint) ([]dto.CustomerResponse, error) {
|
||||
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
|
||||
}
|
||||
results := make([]dto.CustomerResponse, 0, len(customers))
|
||||
@@ -58,14 +67,14 @@ func (s *CustomerService) GetAll() ([]dto.CustomerResponse, error) {
|
||||
// GetByID 根据 ID 获取客户 // Logan.Li
|
||||
func (s *CustomerService) GetByID(id uint) (*dto.CustomerResponse, error) {
|
||||
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("客户不存在")
|
||||
}
|
||||
resp := toCustomerResponse(customer)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// Create 创建客户 // Logan.Li
|
||||
// Create 创建客户并关联产品 // Logan.Li
|
||||
func (s *CustomerService) Create(req dto.CustomerCreate) (*dto.CustomerResponse, error) {
|
||||
status := 1
|
||||
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 {
|
||||
return nil, errors.New("创建客户失败")
|
||||
}
|
||||
resp := toCustomerResponse(customer)
|
||||
return &resp, nil
|
||||
if err := s.replaceProducts(customer.ID, req.ProductIDs); err != 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) {
|
||||
var customer model.Customer
|
||||
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 {
|
||||
return nil, errors.New("更新客户失败")
|
||||
}
|
||||
s.db.First(&customer, id)
|
||||
resp := toCustomerResponse(customer)
|
||||
return &resp, nil
|
||||
// ProductIDs 为 nil 时不改关联;非 nil(含空切片)则全量替换 // Logan.Li
|
||||
if req.ProductIDs != nil {
|
||||
if err := s.replaceProducts(id, *req.ProductIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return s.GetByID(id)
|
||||
}
|
||||
|
||||
// Delete 删除客户(软删除) // Logan.Li
|
||||
@@ -121,6 +136,7 @@ func (s *CustomerService) Delete(id uint) error {
|
||||
if count > 0 {
|
||||
return errors.New("该客户下存在 License,无法删除")
|
||||
}
|
||||
_ = s.db.Where("customer_id = ?", id).Delete(&model.CustomerProduct{}).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
|
||||
}
|
||||
|
||||
// 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
|
||||
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{
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
Company: c.Company,
|
||||
Email: c.Email,
|
||||
Phone: c.Phone,
|
||||
Address: c.Address,
|
||||
Notes: c.Notes,
|
||||
Status: c.Status,
|
||||
CreatedAt: c.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
UpdatedAt: c.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
Company: c.Company,
|
||||
Email: c.Email,
|
||||
Phone: c.Phone,
|
||||
Address: c.Address,
|
||||
Notes: c.Notes,
|
||||
Status: c.Status,
|
||||
ProductIDs: ids,
|
||||
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 (
|
||||
"backend/internal/dto"
|
||||
"backend/internal/model"
|
||||
customersvc "backend/internal/service/customer"
|
||||
"backend/utils"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -137,6 +138,7 @@ func (s *LicenseService) Create(req dto.LicenseCreate) ([]dto.LicenseResponse, e
|
||||
if err := s.db.Create(&licenses).Error; err != nil {
|
||||
return nil, errors.New("创建 License 失败")
|
||||
}
|
||||
_ = customersvc.AttachProduct(s.db, req.CustomerID, req.ProductID)
|
||||
|
||||
results := make([]dto.LicenseResponse, 0, len(licenses))
|
||||
for _, l := range licenses {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"backend/config"
|
||||
"backend/internal/dto"
|
||||
"backend/internal/model"
|
||||
customersvc "backend/internal/service/customer"
|
||||
"backend/utils"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -139,6 +140,7 @@ func (s *OfflineIssuer) Issue(req dto.OfflineLicenseCreate) (*dto.OfflineLicense
|
||||
if err := s.db.Create(&lic).Error; err != nil {
|
||||
return nil, errors.New("保存离线 License 失败")
|
||||
}
|
||||
_ = customersvc.AttachProduct(s.db, customer.ID, product.ID)
|
||||
|
||||
activation := model.Activation{
|
||||
LicenseID: lic.ID,
|
||||
|
||||
@@ -3,6 +3,7 @@ package order
|
||||
import (
|
||||
"backend/internal/dto"
|
||||
"backend/internal/model"
|
||||
customersvc "backend/internal/service/customer"
|
||||
"backend/internal/service/payment"
|
||||
"backend/utils"
|
||||
"errors"
|
||||
@@ -142,6 +143,7 @@ func (s *OrderService) CreateOrder(req dto.CreateOrderRequest, clientIP, userAge
|
||||
if err := s.db.Create(&order).Error; err != nil {
|
||||
return nil, errors.New("创建订单失败")
|
||||
}
|
||||
_ = customersvc.AttachProduct(s.db, customer.ID, product.ID)
|
||||
|
||||
// 6. 生成收银台地址 // Logan.Li
|
||||
var checkoutURL string
|
||||
@@ -365,6 +367,7 @@ func (s *OrderService) PaymentWebhook(req dto.PaymentWebhookRequest, clientIP st
|
||||
if err := tx.Create(&lic).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
_ = customersvc.AttachProduct(tx, order.CustomerID, order.ProductID)
|
||||
if err := tx.Model(&model.Order{}).Where("id = ?", order.ID).
|
||||
Update("license_id", lic.ID).Error; err != nil {
|
||||
return err
|
||||
@@ -471,6 +474,7 @@ func (s *OrderService) ManualGrant(req dto.ManualGrantRequest, operatorID uint,
|
||||
if err := s.db.Create(&lic).Error; err != nil {
|
||||
return nil, errors.New("创建 License 失败")
|
||||
}
|
||||
_ = customersvc.AttachProduct(s.db, customer.ID, req.ProductID)
|
||||
|
||||
// 创建/更新权益记录 // Logan.Li
|
||||
renewable := plan.PlanType != "perpetual" && plan.PlanType != "lifetime"
|
||||
|
||||
@@ -2,14 +2,57 @@
|
||||
<div>
|
||||
<el-card shadow="never">
|
||||
<div class="toolbar">
|
||||
<el-input v-model="keyword" placeholder="搜索客户/邮箱/公司" clearable style="width: 260px" @keyup.enter="loadData" @clear="loadData" />
|
||||
<el-button type="primary" @click="openCreate">新增客户</el-button>
|
||||
<div class="filters">
|
||||
<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>
|
||||
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="客户可关联多个产品。按产品筛选后即可圈选通知群体;发证/购买会自动写入产品关联。"
|
||||
style="margin-bottom: 12px"
|
||||
/>
|
||||
|
||||
<el-table :data="list" v-loading="loading" stripe>
|
||||
<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 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 label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
@@ -32,12 +75,24 @@
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑客户' : '新增客户'" width="520px">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
|
||||
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑客户' : '新增客户'" width="560px" @closed="onDialogClosed">
|
||||
<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="公司"><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="电话"><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.notes" type="textarea" :rows="2" /></el-form-item>
|
||||
</el-form>
|
||||
@@ -50,50 +105,91 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getCustomers, createCustomer, updateCustomer, deleteCustomer, updateCustomerStatus } from '@/api/customer'
|
||||
import { getAllProducts } from '@/api/product'
|
||||
|
||||
const list = ref([])
|
||||
const products = ref([])
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
const productFilter = ref(null)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
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 = {
|
||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||
email: [{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function search() {
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
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 || []
|
||||
total.value = res.data.total
|
||||
} finally { loading.value = false }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function onDialogClosed() {
|
||||
formRef.value?.clearValidate?.()
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
try {
|
||||
if (form.id) await updateCustomer(form.id, form)
|
||||
else await createCustomer(form)
|
||||
const payload = {
|
||||
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
|
||||
loadData()
|
||||
} catch { /* */ }
|
||||
@@ -111,9 +207,29 @@ async function toggleStatus(row, val) {
|
||||
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>
|
||||
|
||||
<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>
|
||||
Reference in new issue
Block a user