工单可以关联客户
This commit is contained in:
@@ -5,3 +5,39 @@
|
||||
- 全面重新分析代码结构,更新并精简 MEMORY.md(整合后端 main.go 启动流程、apiSysAdmin 完整路由、前端路由守卫逻辑、stores/user.js isSysAdmin 机制等)
|
||||
- 将 SysAdminView.vue 拆分为三个子组件:`src/views/sysadmin/UsersTab.vue`(用户管理+详情弹窗)、`GroupsTab.vue`(用户组+添加/移除成员)、`LogsTab.vue`(登录失败日志);父组件改用 v-show 保持子组件挂载,UsersTab 自身 onMounted 加载数据,Groups/Logs 由父组件 watch(activeTab) 懒加载
|
||||
- 将 SysAdminView.vue 整体移入 `src/views/sysadmin/` 目录,并更新 router/index.js 中的引用路径为 `@/views/sysadmin/SysAdminView.vue`
|
||||
- 客户模块权限改造:移除 `/getinfo` 返回的 `isCustomerAdmin`,改为按记录返回 `edit` 权限标记
|
||||
- 后端 `apiCustomer.go`:新增 `canModifyCustomer()` 函数(创建者或管理员可编辑),`/list` 每条记录附加 `edit` 字段,`/get` 返回 `canModify`,`/update` 和 `/delete` 改用 `canModifyCustomer` 校验权限
|
||||
- 后端 `apiUsers.go`:移除 `isCustomerAdmin` 返回
|
||||
- 前端 `stores/user.js`:移除 `isCustomerAdmin` 状态
|
||||
- 前端 `CustomerList.vue`:改用 `customer.edit` 控制编辑/删除按钮显示,新增按钮始终显示(后端会校验权限)
|
||||
- 客户称呼选项调整:移除"夫人"/"博士",新增"单位"作为默认选项
|
||||
- `CustomerFormModal.vue`:`titleOptions` 改为 `['Unit', 'Mr', 'Ms']`,默认值改为 `'Unit'`
|
||||
- `zh-CN.json`:新增 `salutation_unit: "单位"`,移除 `salutation_mrs`/`salutation_dr`
|
||||
- `en.json`:新增 `salutation_unit: "Unit"`,移除 `salutation_mrs`/`salutation_dr`
|
||||
- 后端 `apiCustomer.go`:`Title` 字段注释改为 `称呼:Unit/Mr/Ms`
|
||||
- 客户表单字段顺序调整:姓在前(必填),名在后(非必填)
|
||||
- `CustomerFormModal.vue`:表单 grid 改为 姓 → 名,移除 `first_name` 必填验证
|
||||
- `CustomerList.vue`:列表显示改为 `last_name + first_name` 顺序
|
||||
- 创建客户详情页 `CustomerDetail.vue`
|
||||
- 显示客户基本信息(姓名、称呼、创建时间、创建者)
|
||||
- 显示电话列表(含主号码标记)
|
||||
- 显示邮箱列表(含主邮箱标记)
|
||||
- 显示单位列表(含主单位标记)
|
||||
- 编辑按钮(仅 `canModify` 为 true 时显示)
|
||||
- 返回按钮
|
||||
- 创建客户编辑页 `CustomerEdit.vue`(包装 `CustomerFormModal`)
|
||||
- 添加路由:`/customer/detail/:id` 和 `/customer/edit/:id`
|
||||
- `CustomerList.vue`:姓名改为可点击链接,跳转到详情页
|
||||
- i18n:添加 `common.back`、`customer.detail_title`、`customer.basic_info`、`customer.created_by`、`customer.not_found` 等翻译
|
||||
- 工单-客户关联功能
|
||||
- `binds.go`: 新增 `TabWorkOrderCustomerBind` 关联表,在 `BindsInit()` 中注册
|
||||
- `apiWorkOrder.go`: `/get` API 返回 `linkedCustomers`;新增 `/link_customer` 和 `/unlink_customer` API
|
||||
- `ShowWorkOrder.vue`: 详情页显示关联客户,支持搜索/关联/解除关联客户
|
||||
- `work_order.js`: 添加 `linkCustomer` 和 `unlinkCustomer` API 方法
|
||||
- `AddEditWorkOrder.vue`: 新增工单时支持搜索并关联客户
|
||||
- i18n: 添加 `linked_customer`、`linked_customers`、`link_customer_placeholder` 等翻译
|
||||
- 工单编辑页支持关联物品和客户(多选)
|
||||
- `AddEditWorkOrder.vue`: 移除 `v-if="!isEdit"` 限制,编辑模式也显示关联物品/客户搜索框
|
||||
- `AddEditWorkOrder.vue`: 编辑模式加载时回填 `selectedItems` 和 `selectedCustomers`
|
||||
- `AddEditWorkOrder.vue`: 编辑提交时发送 `item_ids` 和 `customer_ids`
|
||||
- `apiWorkOrder.go`: `/update` 接口新增 `ItemIDs` 和 `CustomerIDs` 字段,重建物品/客户关联绑定
|
||||
|
||||
@@ -1,11 +1,511 @@
|
||||
package routers
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
import (
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"ops/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
customerUserGroup TabUserGroups
|
||||
customerAdmins []uint
|
||||
)
|
||||
|
||||
// TabCustomer 客户主表
|
||||
type TabCustomer struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
FirstName string `gorm:"size:100;comment:名" json:"first_name"`
|
||||
LastName string `gorm:"size:100;comment:姓" json:"last_name"`
|
||||
Title string `gorm:"size:20;comment:称呼:Unit/Mr/Ms" json:"title"`
|
||||
CreatedBy uint `gorm:"not null;comment:创建人ID" json:"created_by"`
|
||||
CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime" json:"created_at"`
|
||||
UpdatedAt *time.Time `gorm:"type:datetime;autoUpdateTime" json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
|
||||
}
|
||||
|
||||
// TabCustomerPhone 客户电话绑定表
|
||||
type TabCustomerPhone struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CustomerID uint `gorm:"not null;index;comment:关联客户ID" json:"customer_id"`
|
||||
Prefix string `gorm:"size:10;comment:地区前缀:86/852/853" json:"prefix"`
|
||||
Phone string `gorm:"size:50;comment:电话号码" json:"phone"`
|
||||
Label string `gorm:"size:20;comment:标签:mobile/work/home/other" json:"label"`
|
||||
IsPrimary bool `gorm:"default:false;comment:是否主号码" json:"is_primary"`
|
||||
CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
// TabCustomerEmail 客户邮箱绑定表
|
||||
type TabCustomerEmail struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CustomerID uint `gorm:"not null;index;comment:关联客户ID" json:"customer_id"`
|
||||
Email string `gorm:"size:200;comment:邮箱地址" json:"email"`
|
||||
Label string `gorm:"size:20;comment:标签:work/personal/other" json:"label"`
|
||||
IsPrimary bool `gorm:"default:false;comment:是否主邮箱" json:"is_primary"`
|
||||
CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
// TabCustomerCompany 客户单位绑定表
|
||||
type TabCustomerCompany struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CustomerID uint `gorm:"not null;index;comment:关联客户ID" json:"customer_id"`
|
||||
CompanyName string `gorm:"size:200;comment:单位名称" json:"company_name"`
|
||||
Department string `gorm:"size:100;comment:部门" json:"department"`
|
||||
Position string `gorm:"size:100;comment:职位" json:"position"`
|
||||
IsPrimary bool `gorm:"default:false;comment:是否主单位" json:"is_primary"`
|
||||
CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
// TabCustomerLog 客户操作日志
|
||||
type TabCustomerLog struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
CustomerID uint `gorm:"not null;index;comment:关联客户ID"`
|
||||
UserID uint `gorm:"not null;comment:操作人ID"`
|
||||
ActionType string `gorm:"size:50;not null;comment:操作类型:create/update/delete/query"`
|
||||
OldContent string `gorm:"type:text;comment:修改前内容(JSON)"`
|
||||
NewContent string `gorm:"type:text;comment:修改后内容(JSON)"`
|
||||
IP string `gorm:"size:50;comment:操作IP"`
|
||||
Remark string `gorm:"size:500;comment:备注"`
|
||||
CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime;comment:操作时间"`
|
||||
}
|
||||
|
||||
type From_customer_add struct {
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Title string `json:"title"`
|
||||
Phones []struct {
|
||||
Prefix string `json:"prefix"`
|
||||
Phone string `json:"phone"`
|
||||
Label string `json:"label"`
|
||||
IsPrimary bool `json:"is_primary"`
|
||||
} `json:"phones"`
|
||||
Emails []struct {
|
||||
Email string `json:"email"`
|
||||
Label string `json:"label"`
|
||||
IsPrimary bool `json:"is_primary"`
|
||||
} `json:"emails"`
|
||||
Companies []struct {
|
||||
CompanyName string `json:"company_name"`
|
||||
Department string `json:"department"`
|
||||
Position string `json:"position"`
|
||||
IsPrimary bool `json:"is_primary"`
|
||||
} `json:"companies"`
|
||||
}
|
||||
|
||||
type From_customer_update struct {
|
||||
ID uint `json:"id"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Title string `json:"title"`
|
||||
Phones []struct {
|
||||
Prefix string `json:"prefix"`
|
||||
Phone string `json:"phone"`
|
||||
Label string `json:"label"`
|
||||
IsPrimary bool `json:"is_primary"`
|
||||
} `json:"phones"`
|
||||
Emails []struct {
|
||||
Email string `json:"email"`
|
||||
Label string `json:"label"`
|
||||
IsPrimary bool `json:"is_primary"`
|
||||
} `json:"emails"`
|
||||
Companies []struct {
|
||||
CompanyName string `json:"company_name"`
|
||||
Department string `json:"department"`
|
||||
Position string `json:"position"`
|
||||
IsPrimary bool `json:"is_primary"`
|
||||
} `json:"companies"`
|
||||
}
|
||||
|
||||
// CustomerUpdateAdminsCash 更新客户管理员缓存
|
||||
func CustomerUpdateAdminsCash() {
|
||||
customerAdmins = nil
|
||||
customerAdmins = append(customerAdmins, 1) // id=1 系统管理员默认拥有所有权限
|
||||
var binds []TabUserGroupBinds
|
||||
models.DB.Where("group_id = ?", customerUserGroup.ID).Find(&binds)
|
||||
for _, item := range binds {
|
||||
if !slices.Contains(customerAdmins, item.UserID) {
|
||||
customerAdmins = append(customerAdmins, item.UserID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// customerAdminCheck 检查用户是否为客户管理员
|
||||
func customerAdminCheck(userID uint) bool {
|
||||
return slices.Contains(customerAdmins, userID)
|
||||
}
|
||||
|
||||
// canModifyCustomer 判断是否有权限修改/删除客户(创建者或管理员)
|
||||
func canModifyCustomer(userID, creatorUserID uint) bool {
|
||||
if slices.Contains(customerAdmins, userID) {
|
||||
return true
|
||||
}
|
||||
return userID == creatorUserID
|
||||
}
|
||||
|
||||
// ApiCustomerInit 初始化客户模块
|
||||
func ApiCustomerInit() {
|
||||
// 自动创建 customer_admin 用户组
|
||||
models.DB.Where("name = ?", "customer_admin").FirstOrCreate(&customerUserGroup, TabUserGroups{
|
||||
Name: "customer_admin",
|
||||
Type: "usergroup",
|
||||
})
|
||||
|
||||
// 自动迁移客户相关表
|
||||
models.DB.AutoMigrate(
|
||||
&TabCustomer{},
|
||||
&TabCustomerPhone{},
|
||||
&TabCustomerEmail{},
|
||||
&TabCustomerCompany{},
|
||||
&TabCustomerLog{},
|
||||
)
|
||||
|
||||
CustomerUpdateAdminsCash()
|
||||
}
|
||||
|
||||
func ApiCustomer(r *gin.RouterGroup) {
|
||||
// POST /add - 新增客户(需 customer_admin 权限)
|
||||
r.POST("/add", func(ctx *gin.Context) {
|
||||
isAuth, user, data := AuthenticationAuthority(ctx)
|
||||
if !isAuth {
|
||||
ReturnJson(ctx, "userNoLogin", nil)
|
||||
return
|
||||
}
|
||||
if !customerAdminCheck(user.ID) {
|
||||
ReturnJson(ctx, "permission_denied", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var params From_customer_add
|
||||
if err := decodeJSON(data, ¶ms); err != nil {
|
||||
ReturnJson(ctx, "parameErr", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 创建客户
|
||||
customer := TabCustomer{
|
||||
FirstName: params.FirstName,
|
||||
LastName: params.LastName,
|
||||
Title: params.Title,
|
||||
CreatedBy: user.ID,
|
||||
}
|
||||
if err := models.DB.Create(&customer).Error; err != nil {
|
||||
ReturnJson(ctx, "dbErr", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 写入电话
|
||||
for _, p := range params.Phones {
|
||||
models.DB.Create(&TabCustomerPhone{
|
||||
CustomerID: customer.ID,
|
||||
Prefix: p.Prefix,
|
||||
Phone: p.Phone,
|
||||
Label: p.Label,
|
||||
IsPrimary: p.IsPrimary,
|
||||
})
|
||||
}
|
||||
|
||||
// 写入邮箱
|
||||
for _, e := range params.Emails {
|
||||
models.DB.Create(&TabCustomerEmail{
|
||||
CustomerID: customer.ID,
|
||||
Email: e.Email,
|
||||
Label: e.Label,
|
||||
IsPrimary: e.IsPrimary,
|
||||
})
|
||||
}
|
||||
|
||||
// 写入单位
|
||||
for _, c := range params.Companies {
|
||||
models.DB.Create(&TabCustomerCompany{
|
||||
CustomerID: customer.ID,
|
||||
CompanyName: c.CompanyName,
|
||||
Department: c.Department,
|
||||
Position: c.Position,
|
||||
IsPrimary: c.IsPrimary,
|
||||
})
|
||||
}
|
||||
|
||||
// 写日志
|
||||
models.DB.Create(&TabCustomerLog{
|
||||
CustomerID: customer.ID,
|
||||
UserID: user.ID,
|
||||
ActionType: "create",
|
||||
IP: ctx.ClientIP(),
|
||||
})
|
||||
|
||||
ReturnJson(ctx, "apiOK", gin.H{"id": customer.ID})
|
||||
})
|
||||
|
||||
// POST /update - 编辑客户(创建者或管理员可操作)
|
||||
r.POST("/update", func(ctx *gin.Context) {
|
||||
isAuth, user, data := AuthenticationAuthority(ctx)
|
||||
if !isAuth {
|
||||
ReturnJson(ctx, "userNoLogin", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var params From_customer_update
|
||||
if err := decodeJSON(data, ¶ms); err != nil {
|
||||
ReturnJson(ctx, "parameErr", nil)
|
||||
return
|
||||
}
|
||||
if params.ID == 0 {
|
||||
ReturnJson(ctx, "parameErr", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 查找客户
|
||||
var customer TabCustomer
|
||||
if err := models.DB.Unscoped().First(&customer, params.ID).Error; err != nil {
|
||||
ReturnJson(ctx, "parameErr", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 权限校验:只有创建者或管理员可以修改
|
||||
if !canModifyCustomer(user.ID, customer.CreatedBy) {
|
||||
ReturnJson(ctx, "no_permission", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 更新主表
|
||||
models.DB.Model(&customer).Updates(map[string]interface{}{
|
||||
"first_name": params.FirstName,
|
||||
"last_name": params.LastName,
|
||||
"title": params.Title,
|
||||
})
|
||||
|
||||
// 重建绑定表:删除旧的,写入新的
|
||||
models.DB.Where("customer_id = ?", customer.ID).Delete(&TabCustomerPhone{})
|
||||
for _, p := range params.Phones {
|
||||
models.DB.Create(&TabCustomerPhone{
|
||||
CustomerID: customer.ID,
|
||||
Prefix: p.Prefix,
|
||||
Phone: p.Phone,
|
||||
Label: p.Label,
|
||||
IsPrimary: p.IsPrimary,
|
||||
})
|
||||
}
|
||||
|
||||
models.DB.Where("customer_id = ?", customer.ID).Delete(&TabCustomerEmail{})
|
||||
for _, e := range params.Emails {
|
||||
models.DB.Create(&TabCustomerEmail{
|
||||
CustomerID: customer.ID,
|
||||
Email: e.Email,
|
||||
Label: e.Label,
|
||||
IsPrimary: e.IsPrimary,
|
||||
})
|
||||
}
|
||||
|
||||
models.DB.Where("customer_id = ?", customer.ID).Delete(&TabCustomerCompany{})
|
||||
for _, c := range params.Companies {
|
||||
models.DB.Create(&TabCustomerCompany{
|
||||
CustomerID: customer.ID,
|
||||
CompanyName: c.CompanyName,
|
||||
Department: c.Department,
|
||||
Position: c.Position,
|
||||
IsPrimary: c.IsPrimary,
|
||||
})
|
||||
}
|
||||
|
||||
// 写日志
|
||||
models.DB.Create(&TabCustomerLog{
|
||||
CustomerID: customer.ID,
|
||||
UserID: user.ID,
|
||||
ActionType: "update",
|
||||
OldContent: "update", // 简化处理
|
||||
IP: ctx.ClientIP(),
|
||||
})
|
||||
|
||||
ReturnJson(ctx, "apiOK", nil)
|
||||
})
|
||||
|
||||
// POST /delete - 软删除客户(创建者或管理员可操作)
|
||||
r.POST("/delete", func(ctx *gin.Context) {
|
||||
isAuth, user, data := AuthenticationAuthority(ctx)
|
||||
if !isAuth {
|
||||
ReturnJson(ctx, "userNoLogin", nil)
|
||||
return
|
||||
}
|
||||
|
||||
type Req struct {
|
||||
ID uint `json:"id"`
|
||||
}
|
||||
var req Req
|
||||
if err := decodeJSON(data, &req); err != nil || req.ID == 0 {
|
||||
ReturnJson(ctx, "parameErr", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var customer TabCustomer
|
||||
if err := models.DB.First(&customer, req.ID).Error; err != nil {
|
||||
ReturnJson(ctx, "parameErr", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 权限校验:只有创建者或管理员可以删除
|
||||
if !canModifyCustomer(user.ID, customer.CreatedBy) {
|
||||
ReturnJson(ctx, "no_permission", nil)
|
||||
return
|
||||
}
|
||||
|
||||
models.DB.Delete(&customer)
|
||||
|
||||
// 写日志
|
||||
models.DB.Create(&TabCustomerLog{
|
||||
CustomerID: customer.ID,
|
||||
UserID: user.ID,
|
||||
ActionType: "delete",
|
||||
IP: ctx.ClientIP(),
|
||||
})
|
||||
|
||||
ReturnJson(ctx, "apiOK", nil)
|
||||
})
|
||||
|
||||
// POST /list - 客户列表(登录用户可读)
|
||||
r.POST("/list", func(ctx *gin.Context) {
|
||||
isAuth, user, data := AuthenticationAuthority(ctx)
|
||||
if !isAuth {
|
||||
ReturnJson(ctx, "userNoLogin", nil)
|
||||
return
|
||||
}
|
||||
|
||||
type Req struct {
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Search string `json:"search"`
|
||||
}
|
||||
var req Req
|
||||
if err := decodeJSON(data, &req); err != nil {
|
||||
req.Page = 1
|
||||
req.PageSize = 20
|
||||
}
|
||||
if req.Page < 1 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PageSize < 1 || req.PageSize > 100 {
|
||||
req.PageSize = 20
|
||||
}
|
||||
|
||||
offset := (req.Page - 1) * req.PageSize
|
||||
|
||||
// 子查询:获取每个客户的主电话/主邮箱/主单位用于搜索和展示
|
||||
query := models.DB.Model(&TabCustomer{})
|
||||
|
||||
if req.Search != "" {
|
||||
search := "%" + req.Search + "%"
|
||||
query = query.Where(
|
||||
models.DB.Where("first_name LIKE ? OR last_name LIKE ?", search, search).
|
||||
Or("id IN (?)", models.DB.Table("tab_customer_phones").Select("customer_id").Where("phone LIKE ?", search)).
|
||||
Or("id IN (?)", models.DB.Table("tab_customer_emails").Select("customer_id").Where("email LIKE ?", search)).
|
||||
Or("id IN (?)", models.DB.Table("tab_customer_companies").Select("customer_id").Where("company_name LIKE ?", search)),
|
||||
)
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var customers []TabCustomer
|
||||
query.Order("id DESC").Offset(offset).Limit(req.PageSize).Find(&customers)
|
||||
|
||||
// 组装列表数据(含主电话/主邮箱/主单位 + 编辑权限)
|
||||
type CustomerListItem struct {
|
||||
TabCustomer
|
||||
PrimaryPhone string `json:"primary_phone"`
|
||||
PrimaryEmail string `json:"primary_email"`
|
||||
PrimaryCompany string `json:"primary_company"`
|
||||
Edit bool `json:"edit"`
|
||||
}
|
||||
|
||||
var list []CustomerListItem
|
||||
for _, c := range customers {
|
||||
item := CustomerListItem{
|
||||
TabCustomer: c,
|
||||
Edit: canModifyCustomer(user.ID, c.CreatedBy),
|
||||
}
|
||||
|
||||
var phone TabCustomerPhone
|
||||
if err := models.DB.Where("customer_id = ? AND is_primary = ?", c.ID, true).First(&phone).Error; err == nil {
|
||||
item.PrimaryPhone = phone.Phone
|
||||
} else if err := models.DB.Where("customer_id = ?", c.ID).First(&phone).Error; err == nil {
|
||||
item.PrimaryPhone = phone.Phone
|
||||
}
|
||||
|
||||
var email TabCustomerEmail
|
||||
if err := models.DB.Where("customer_id = ? AND is_primary = ?", c.ID, true).First(&email).Error; err == nil {
|
||||
item.PrimaryEmail = email.Email
|
||||
} else if err := models.DB.Where("customer_id = ?", c.ID).First(&email).Error; err == nil {
|
||||
item.PrimaryEmail = email.Email
|
||||
}
|
||||
|
||||
var company TabCustomerCompany
|
||||
if err := models.DB.Where("customer_id = ? AND is_primary = ?", c.ID, true).First(&company).Error; err == nil {
|
||||
item.PrimaryCompany = company.CompanyName
|
||||
} else if err := models.DB.Where("customer_id = ?", c.ID).First(&company).Error; err == nil {
|
||||
item.PrimaryCompany = company.CompanyName
|
||||
}
|
||||
|
||||
list = append(list, item)
|
||||
}
|
||||
|
||||
ReturnJson(ctx, "apiOK", gin.H{
|
||||
"customers": list,
|
||||
"total": total,
|
||||
"page": req.Page,
|
||||
"page_size": req.PageSize,
|
||||
})
|
||||
})
|
||||
|
||||
// POST /get - 获取客户详情(登录用户可读)
|
||||
r.POST("/get", func(ctx *gin.Context) {
|
||||
isAuth, user, data := AuthenticationAuthority(ctx)
|
||||
if !isAuth {
|
||||
ReturnJson(ctx, "userNoLogin", nil)
|
||||
return
|
||||
}
|
||||
|
||||
type Req struct {
|
||||
ID uint `json:"id"`
|
||||
}
|
||||
var req Req
|
||||
if err := decodeJSON(data, &req); err != nil || req.ID == 0 {
|
||||
ReturnJson(ctx, "parameErr", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var customer TabCustomer
|
||||
if err := models.DB.First(&customer, req.ID).Error; err != nil {
|
||||
ReturnJson(ctx, "parameErr", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取电话列表
|
||||
var phones []TabCustomerPhone
|
||||
models.DB.Where("customer_id = ?", req.ID).Find(&phones)
|
||||
|
||||
// 获取邮箱列表
|
||||
var emails []TabCustomerEmail
|
||||
models.DB.Where("customer_id = ?", req.ID).Find(&emails)
|
||||
|
||||
// 获取单位列表
|
||||
var companies []TabCustomerCompany
|
||||
models.DB.Where("customer_id = ?", req.ID).Find(&companies)
|
||||
|
||||
// 写查询日志
|
||||
models.DB.Create(&TabCustomerLog{
|
||||
CustomerID: req.ID,
|
||||
ActionType: "query",
|
||||
IP: ctx.ClientIP(),
|
||||
})
|
||||
|
||||
ReturnJson(ctx, "apiOK", gin.H{
|
||||
"customer": customer,
|
||||
"phones": phones,
|
||||
"emails": emails,
|
||||
"companies": companies,
|
||||
"canModify": canModifyCustomer(user.ID, customer.CreatedBy),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -391,6 +391,8 @@ func ApiSysAdmin(r *gin.RouterGroup) {
|
||||
WorkOrderUpdateAdminsCash()
|
||||
case "warehouse_admin":
|
||||
WarehouseUpdateAdminsCash()
|
||||
case "customer_admin":
|
||||
CustomerUpdateAdminsCash()
|
||||
}
|
||||
|
||||
ReturnJson(ctx, "apiOK", nil)
|
||||
@@ -444,6 +446,8 @@ func ApiSysAdmin(r *gin.RouterGroup) {
|
||||
WorkOrderUpdateAdminsCash()
|
||||
case "warehouse_admin":
|
||||
WarehouseUpdateAdminsCash()
|
||||
case "customer_admin":
|
||||
CustomerUpdateAdminsCash()
|
||||
}
|
||||
|
||||
ReturnJson(ctx, "apiOK", nil)
|
||||
|
||||
@@ -114,7 +114,8 @@ func ApiWorkOrder(r *gin.RouterGroup) {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Photos []string `json:"photos"`
|
||||
ItemID *uint `json:"item_id"`
|
||||
ItemIDs []uint `json:"item_ids"`
|
||||
CustomerIDs []uint `json:"customer_ids"`
|
||||
}
|
||||
var from FromAdd
|
||||
if err := decodeJSON(data, &from); err != nil || from.Title == "" {
|
||||
@@ -149,13 +150,26 @@ func ApiWorkOrder(r *gin.RouterGroup) {
|
||||
}
|
||||
}
|
||||
|
||||
// 绑定物品
|
||||
if from.ItemID != nil && *from.ItemID > 0 {
|
||||
models.DB.Create(&TabWarehouseItemWorkOrderBind{
|
||||
ItemID: *from.ItemID,
|
||||
WorkOrderID: order.ID,
|
||||
CreatorID: user.ID,
|
||||
})
|
||||
// 绑定物品(支持多个)
|
||||
for _, itemID := range from.ItemIDs {
|
||||
if itemID > 0 {
|
||||
models.DB.Create(&TabWarehouseItemWorkOrderBind{
|
||||
ItemID: itemID,
|
||||
WorkOrderID: order.ID,
|
||||
CreatorID: user.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 绑定客户(支持多个)
|
||||
for _, customerID := range from.CustomerIDs {
|
||||
if customerID > 0 {
|
||||
models.DB.Create(&TabWorkOrderCustomerBind{
|
||||
WorkOrderID: order.ID,
|
||||
CustomerID: customerID,
|
||||
CreatorID: user.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 写创建 commit
|
||||
@@ -190,17 +204,19 @@ func ApiWorkOrder(r *gin.RouterGroup) {
|
||||
return
|
||||
}
|
||||
|
||||
type FromUpdate struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Photos []string `json:"photos"`
|
||||
}
|
||||
var from FromUpdate
|
||||
if err := decodeJSON(data, &from); err != nil || from.ID == 0 || from.Title == "" {
|
||||
ReturnJson(ctx, "jsonErr", nil)
|
||||
return
|
||||
}
|
||||
type FromUpdate struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Photos []string `json:"photos"`
|
||||
ItemIDs []uint `json:"item_ids"`
|
||||
CustomerIDs []uint `json:"customer_ids"`
|
||||
}
|
||||
var from FromUpdate
|
||||
if err := decodeJSON(data, &from); err != nil || from.ID == 0 || from.Title == "" {
|
||||
ReturnJson(ctx, "jsonErr", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 校验图片哈希
|
||||
for _, hash := range from.Photos {
|
||||
@@ -240,6 +256,25 @@ func ApiWorkOrder(r *gin.RouterGroup) {
|
||||
}
|
||||
}
|
||||
|
||||
// 重建物品关联绑定
|
||||
models.DB.Where("work_order_id = ?", from.ID).Delete(&TabWarehouseItemWorkOrderBind{})
|
||||
for _, itemID := range from.ItemIDs {
|
||||
models.DB.Create(&TabWarehouseItemWorkOrderBind{
|
||||
WorkOrderID: from.ID,
|
||||
ItemID: itemID,
|
||||
})
|
||||
}
|
||||
|
||||
// 重建客户关联绑定
|
||||
models.DB.Where("work_order_id = ?", from.ID).Delete(&TabWorkOrderCustomerBind{})
|
||||
for _, customerID := range from.CustomerIDs {
|
||||
models.DB.Create(&TabWorkOrderCustomerBind{
|
||||
WorkOrderID: from.ID,
|
||||
CustomerID: customerID,
|
||||
CreatorID: user.ID,
|
||||
})
|
||||
}
|
||||
|
||||
newContent, _ := json.Marshal(from)
|
||||
models.DB.Create(&TabWorkOrderLog{
|
||||
WorkOrderID: from.ID,
|
||||
@@ -414,16 +449,156 @@ func ApiWorkOrder(r *gin.RouterGroup) {
|
||||
}
|
||||
}
|
||||
|
||||
// 关联客户
|
||||
type LinkedCustomer struct {
|
||||
ID uint `json:"id"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
PrimaryPhone string `json:"primary_phone"`
|
||||
}
|
||||
var linkedCustomers []LinkedCustomer
|
||||
var customerBinds []TabWorkOrderCustomerBind
|
||||
models.DB.Where("work_order_id = ?", from.ID).Find(&customerBinds)
|
||||
if len(customerBinds) > 0 {
|
||||
var customerIDs []uint
|
||||
for _, b := range customerBinds {
|
||||
customerIDs = append(customerIDs, b.CustomerID)
|
||||
}
|
||||
var customers []TabCustomer
|
||||
models.DB.Where("id IN ?", customerIDs).Find(&customers)
|
||||
for _, c := range customers {
|
||||
item := LinkedCustomer{
|
||||
ID: c.ID,
|
||||
FirstName: c.FirstName,
|
||||
LastName: c.LastName,
|
||||
PrimaryPhone: "",
|
||||
}
|
||||
// 获取主电话
|
||||
var phone TabCustomerPhone
|
||||
if err := models.DB.Where("customer_id = ? AND is_primary = ?", c.ID, true).First(&phone).Error; err == nil {
|
||||
item.PrimaryPhone = phone.Phone
|
||||
} else if err := models.DB.Where("customer_id = ?", c.ID).First(&phone).Error; err == nil {
|
||||
item.PrimaryPhone = phone.Phone
|
||||
}
|
||||
linkedCustomers = append(linkedCustomers, item)
|
||||
}
|
||||
}
|
||||
|
||||
ReturnJson(ctx, "apiOK", gin.H{
|
||||
"order": order,
|
||||
"canModify": canModify,
|
||||
"canCommit": canCommit,
|
||||
"photos": files,
|
||||
"commits": commitsWithPhotos,
|
||||
"linkedItems": linkedItems,
|
||||
"order": order,
|
||||
"canModify": canModify,
|
||||
"canCommit": canCommit,
|
||||
"photos": files,
|
||||
"commits": commitsWithPhotos,
|
||||
"linkedItems": linkedItems,
|
||||
"linkedCustomers": linkedCustomers,
|
||||
})
|
||||
})
|
||||
|
||||
// 关联客户到工单
|
||||
r.POST("/link_customer", func(ctx *gin.Context) {
|
||||
isAuth, user, data := AuthenticationAuthority(ctx)
|
||||
if !isAuth {
|
||||
ReturnJson(ctx, "userCookieError", nil)
|
||||
return
|
||||
}
|
||||
|
||||
type FromLinkCustomer struct {
|
||||
WorkOrderID uint `json:"work_order_id"`
|
||||
CustomerID uint `json:"customer_id"`
|
||||
}
|
||||
var from FromLinkCustomer
|
||||
if err := decodeJSON(data, &from); err != nil || from.WorkOrderID == 0 || from.CustomerID == 0 {
|
||||
ReturnJson(ctx, "parameErr", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查工单是否存在
|
||||
var order TabWorkOrder
|
||||
if err := models.DB.Where("id = ?", from.WorkOrderID).First(&order).Error; err != nil {
|
||||
ReturnJson(ctx, "order_not_found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查客户是否存在
|
||||
var customer TabCustomer
|
||||
if err := models.DB.Where("id = ?", from.CustomerID).First(&customer).Error; err != nil {
|
||||
ReturnJson(ctx, "customer_not_found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否已关联
|
||||
var existingBind TabWorkOrderCustomerBind
|
||||
if err := models.DB.Where("work_order_id = ? AND customer_id = ?", from.WorkOrderID, from.CustomerID).First(&existingBind).Error; err == nil {
|
||||
ReturnJson(ctx, "already_linked", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 创建关联
|
||||
if err := models.DB.Create(&TabWorkOrderCustomerBind{
|
||||
WorkOrderID: from.WorkOrderID,
|
||||
CustomerID: from.CustomerID,
|
||||
CreatorID: user.ID,
|
||||
}).Error; err != nil {
|
||||
ReturnJson(ctx, "dbErr", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 写操作日志
|
||||
models.DB.Create(&TabWorkOrderLog{
|
||||
WorkOrderID: from.WorkOrderID,
|
||||
UserID: user.ID,
|
||||
ActionType: "link_customer",
|
||||
NewContent: parsefmt.Sprintf("关联客户 ID: %d", from.CustomerID),
|
||||
IP: ctx.ClientIP(),
|
||||
})
|
||||
|
||||
ReturnJson(ctx, "apiOK", nil)
|
||||
})
|
||||
|
||||
// 解除工单与客户关联
|
||||
r.POST("/unlink_customer", func(ctx *gin.Context) {
|
||||
isAuth, user, data := AuthenticationAuthority(ctx)
|
||||
if !isAuth {
|
||||
ReturnJson(ctx, "userCookieError", nil)
|
||||
return
|
||||
}
|
||||
|
||||
type FromUnlinkCustomer struct {
|
||||
WorkOrderID uint `json:"work_order_id"`
|
||||
CustomerID uint `json:"customer_id"`
|
||||
}
|
||||
var from FromUnlinkCustomer
|
||||
if err := decodeJSON(data, &from); err != nil || from.WorkOrderID == 0 || from.CustomerID == 0 {
|
||||
ReturnJson(ctx, "parameErr", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查工单是否存在
|
||||
var order TabWorkOrder
|
||||
if err := models.DB.Where("id = ?", from.WorkOrderID).First(&order).Error; err != nil {
|
||||
ReturnJson(ctx, "order_not_found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 删除关联
|
||||
if err := models.DB.Where("work_order_id = ? AND customer_id = ?", from.WorkOrderID, from.CustomerID).Delete(&TabWorkOrderCustomerBind{}).Error; err != nil {
|
||||
ReturnJson(ctx, "dbErr", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 写操作日志
|
||||
models.DB.Create(&TabWorkOrderLog{
|
||||
WorkOrderID: from.WorkOrderID,
|
||||
UserID: user.ID,
|
||||
ActionType: "unlink_customer",
|
||||
NewContent: parsefmt.Sprintf("解除关联客户 ID: %d", from.CustomerID),
|
||||
IP: ctx.ClientIP(),
|
||||
})
|
||||
|
||||
ReturnJson(ctx, "apiOK", nil)
|
||||
})
|
||||
|
||||
// 新增进度 commit(状态推进)
|
||||
r.POST("/commit", func(ctx *gin.Context) {
|
||||
isAuth, user, data := AuthenticationAuthority(ctx)
|
||||
|
||||
@@ -56,6 +56,15 @@ type TabWorkOrderPurchaseOrderBind struct {
|
||||
CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"`
|
||||
}
|
||||
|
||||
// TabWorkOrderCustomerBind 工单与客户关联表
|
||||
type TabWorkOrderCustomerBind struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
WorkOrderID uint `gorm:"not null;index;comment:关联工单ID"`
|
||||
CustomerID uint `gorm:"not null;index;comment:关联客户ID"`
|
||||
CreatorID uint `gorm:"not null;comment:绑定人id"`
|
||||
CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"`
|
||||
}
|
||||
|
||||
type TabWarehouseContainerFileBind struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
ContainerID uint `gorm:"not null;index;comment:关联容器id"`
|
||||
@@ -73,6 +82,7 @@ func BindsInit() {
|
||||
&TabWorkOrderFileBind{},
|
||||
&TabWorkOrderCommitFileBind{},
|
||||
&TabWorkOrderPurchaseOrderBind{},
|
||||
&TabWorkOrderCustomerBind{},
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import api from './index'
|
||||
|
||||
export const customerApi = {
|
||||
// 新增客户
|
||||
add(data) {
|
||||
return api.post('/customer/add', data)
|
||||
},
|
||||
|
||||
// 编辑客户
|
||||
update(data) {
|
||||
return api.post('/customer/update', data)
|
||||
},
|
||||
|
||||
// 删除客户
|
||||
delete(data) {
|
||||
return api.post('/customer/delete', data)
|
||||
},
|
||||
|
||||
// 客户列表
|
||||
list(data) {
|
||||
return api.post('/customer/list', data)
|
||||
},
|
||||
|
||||
// 客户详情
|
||||
get(data) {
|
||||
return api.post('/customer/get', data)
|
||||
},
|
||||
}
|
||||
@@ -45,4 +45,14 @@ export const workOrderApi = {
|
||||
deleteCommit(workOrderId, commitId) {
|
||||
return api.post('/work_order/delete_commit', { workOrderId, commitId })
|
||||
},
|
||||
|
||||
/** 关联客户到工单 */
|
||||
linkCustomer(workOrderId, customerId) {
|
||||
return api.post('/work_order/link_customer', { work_order_id: workOrderId, customer_id: customerId })
|
||||
},
|
||||
|
||||
/** 解除工单与客户关联 */
|
||||
unlinkCustomer(workOrderId, customerId) {
|
||||
return api.post('/work_order/unlink_customer', { work_order_id: workOrderId, customer_id: customerId })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
{
|
||||
"common": {
|
||||
"actions": "Actions",
|
||||
"search": "Search",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"back": "Back"
|
||||
},
|
||||
"week": {
|
||||
"sun": "Sunday",
|
||||
"mon": "Monday",
|
||||
@@ -127,6 +136,13 @@
|
||||
"linked_item_not_found": "No matching items found",
|
||||
"linked_item_selected": "Selected",
|
||||
"clear_linked_item": "Clear",
|
||||
"linked_customer": "Linked Customer",
|
||||
"linked_customers": "Linked Customers",
|
||||
"link_customer_placeholder": "Search and link customer...",
|
||||
"linked_customer_placeholder": "Search customer name or phone...",
|
||||
"linked_customer_not_found": "No matching customers found",
|
||||
"linked_customer_selected": "Selected",
|
||||
"clear_linked_customer": "Clear",
|
||||
"photos": "Photos",
|
||||
"no_photos": "No photos",
|
||||
"status": "Status",
|
||||
@@ -504,5 +520,49 @@
|
||||
"no_admins": "No system admins",
|
||||
"group_list": "Group List",
|
||||
"extended_info": "Extended Info"
|
||||
},
|
||||
"customer": {
|
||||
"title": "Customer Management",
|
||||
"subtitle": "Manage customer information",
|
||||
"add": "Add Customer",
|
||||
"add_title": "Add Customer",
|
||||
"edit_title": "Edit Customer",
|
||||
"delete_title": "Delete Customer",
|
||||
"delete_confirm": "Are you sure you want to delete this customer? This action cannot be undone.",
|
||||
"search_placeholder": "Search by name, phone, email or company...",
|
||||
"name": "Name",
|
||||
"first_name": "First Name",
|
||||
"last_name": "Last Name",
|
||||
"salutation": "Title",
|
||||
"salutation_unit": "Unit",
|
||||
"salutation_mr": "Mr",
|
||||
"salutation_ms": "Ms",
|
||||
"phone": "Phone",
|
||||
"phones": "Phone Numbers",
|
||||
"add_phone": "Add Phone",
|
||||
"phone_number": "Phone Number",
|
||||
"email": "Email",
|
||||
"emails": "Email Addresses",
|
||||
"add_email": "Add Email",
|
||||
"email_address": "Email Address",
|
||||
"company": "Company",
|
||||
"companies": "Companies",
|
||||
"add_company": "Add Company",
|
||||
"company_name": "Company Name",
|
||||
"department": "Department",
|
||||
"position": "Position",
|
||||
"created_at": "Created At",
|
||||
"primary": "Primary",
|
||||
"set_primary": "Set Primary",
|
||||
"label_mobile": "Mobile",
|
||||
"label_work": "Work",
|
||||
"label_home": "Home",
|
||||
"label_other": "Other",
|
||||
"label_personal": "Personal",
|
||||
"no_data": "No customer data",
|
||||
"detail_title": "Customer Detail",
|
||||
"basic_info": "Basic Information",
|
||||
"created_by": "Created By",
|
||||
"not_found": "Customer not found"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
{
|
||||
"common": {
|
||||
"actions": "操作",
|
||||
"search": "搜索",
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"back": "返回"
|
||||
},
|
||||
"week": {
|
||||
"sun": "星期日",
|
||||
"mon": "星期一",
|
||||
@@ -127,6 +136,13 @@
|
||||
"linked_item_not_found": "未找到匹配的物品",
|
||||
"linked_item_selected": "已选择",
|
||||
"clear_linked_item": "清除",
|
||||
"linked_customer": "关联客户",
|
||||
"linked_customers": "关联客户",
|
||||
"link_customer_placeholder": "搜索客户并关联...",
|
||||
"linked_customer_placeholder": "搜索客户姓名或电话...",
|
||||
"linked_customer_not_found": "未找到匹配的客户",
|
||||
"linked_customer_selected": "已选择",
|
||||
"clear_linked_customer": "清除",
|
||||
"photos": "图片",
|
||||
"no_photos": "暂无图片",
|
||||
"status": "状态",
|
||||
@@ -504,5 +520,49 @@
|
||||
"no_admins": "暂无系统管理员",
|
||||
"group_list": "用户组列表",
|
||||
"extended_info": "扩展信息"
|
||||
},
|
||||
"customer": {
|
||||
"title": "客户管理",
|
||||
"subtitle": "管理客户信息",
|
||||
"add": "新增客户",
|
||||
"add_title": "新增客户",
|
||||
"edit_title": "编辑客户",
|
||||
"delete_title": "删除客户",
|
||||
"delete_confirm": "确定要删除此客户吗?此操作不可恢复。",
|
||||
"search_placeholder": "搜索姓名、电话、邮箱或单位...",
|
||||
"name": "姓名",
|
||||
"first_name": "名",
|
||||
"last_name": "姓",
|
||||
"salutation": "称呼",
|
||||
"salutation_unit": "单位",
|
||||
"salutation_mr": "先生",
|
||||
"salutation_ms": "女士",
|
||||
"phone": "电话",
|
||||
"phones": "联系电话",
|
||||
"add_phone": "添加电话",
|
||||
"phone_number": "电话号码",
|
||||
"email": "邮箱",
|
||||
"emails": "邮箱地址",
|
||||
"add_email": "添加邮箱",
|
||||
"email_address": "邮箱地址",
|
||||
"company": "单位",
|
||||
"companies": "所属单位",
|
||||
"add_company": "添加单位",
|
||||
"company_name": "单位名称",
|
||||
"department": "部门",
|
||||
"position": "职位",
|
||||
"created_at": "创建时间",
|
||||
"primary": "主",
|
||||
"set_primary": "设为主",
|
||||
"label_mobile": "手机",
|
||||
"label_work": "工作",
|
||||
"label_home": "家庭",
|
||||
"label_other": "其他",
|
||||
"label_personal": "个人",
|
||||
"no_data": "暂无客户数据",
|
||||
"detail_title": "客户详情",
|
||||
"basic_info": "基本信息",
|
||||
"created_by": "创建者",
|
||||
"not_found": "客户不存在"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,17 +103,32 @@ const router = createRouter({
|
||||
name: 'warehouse-item-edit',
|
||||
component: () => import('@/views/warehouse/WarehouseItemEdit.vue'),
|
||||
},
|
||||
{
|
||||
path: 'admin',
|
||||
name: 'admin',
|
||||
component: () => import('@/views/AdminView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'sysadmin',
|
||||
name: 'sysadmin',
|
||||
component: () => import('@/views/sysadmin/SysAdminView.vue'),
|
||||
meta: { requireSysAdmin: true },
|
||||
},
|
||||
{
|
||||
path: 'customer',
|
||||
name: 'customer',
|
||||
component: () => import('@/views/customer/CustomerList.vue'),
|
||||
},
|
||||
{
|
||||
path: 'customer/detail/:id',
|
||||
name: 'customer-detail',
|
||||
component: () => import('@/views/customer/CustomerDetail.vue'),
|
||||
},
|
||||
{
|
||||
path: 'customer/edit/:id',
|
||||
name: 'customer-edit',
|
||||
component: () => import('@/views/customer/CustomerEdit.vue'),
|
||||
},
|
||||
{
|
||||
path: 'customer/edit/:id',
|
||||
name: 'customer-edit',
|
||||
component: () => import('@/views/customer/CustomerFormModal.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { customerApi } from '@/api/customer'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import AppToast from '@/components/AppToast.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const usersStore = useUsersStore()
|
||||
|
||||
const customer = ref(null)
|
||||
const phones = ref([])
|
||||
const emails = ref([])
|
||||
const companies = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
const toast = ref({ show: false, message: '', type: 'success' })
|
||||
|
||||
// 获取客户详情
|
||||
async function fetchCustomerDetail() {
|
||||
const id = route.params.id
|
||||
if (!id) {
|
||||
router.push('/customer')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await customerApi.get({ id: parseInt(id) })
|
||||
if (res.errCode === 0) {
|
||||
customer.value = res.data.customer
|
||||
phones.value = res.data.phones || []
|
||||
emails.value = res.data.emails || []
|
||||
companies.value = res.data.companies || []
|
||||
// 预加载创建者信息
|
||||
if (customer.value?.created_by) {
|
||||
usersStore.fetchUser(customer.value.created_by)
|
||||
}
|
||||
} else {
|
||||
showToast(res.errMsg || t('message.error'), 'error')
|
||||
router.push('/customer')
|
||||
}
|
||||
} catch {
|
||||
showToast(t('message.error'), 'error')
|
||||
router.push('/customer')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 返回列表
|
||||
function goBack() {
|
||||
router.push('/customer')
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
// 获取标签文本
|
||||
function getLabelText(label) {
|
||||
const key = `customer.label_${label}`
|
||||
const text = t(key)
|
||||
return text === key ? label : text
|
||||
}
|
||||
|
||||
function showToast(message, type = 'success') {
|
||||
toast.value = { show: true, message, type }
|
||||
setTimeout(() => (toast.value.show = false), 3000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchCustomerDetail()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 p-6 dark:bg-dk-base">
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
@click="goBack"
|
||||
class="flex items-center gap-1 text-gray-600 hover:text-gray-900 dark:text-dk-subtle dark:hover:text-dk-text"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{{ t('common.back') }}
|
||||
</button>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-dk-text">{{ t('customer.detail_title') }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600"></div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div v-else-if="customer" class="space-y-6">
|
||||
<!-- Basic Info Card -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-dk-muted dark:bg-dk-card">
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-dk-text">{{ t('customer.basic_info') }}</h2>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="text-sm text-gray-500 dark:text-dk-subtle">{{ t('customer.name') }}</label>
|
||||
<p class="text-lg font-medium text-gray-900 dark:text-dk-text">
|
||||
{{ (customer.last_name || '') + (customer.first_name ? ' ' + customer.first_name : '') }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-500 dark:text-dk-subtle">{{ t('customer.salutation') }}</label>
|
||||
<p class="text-gray-900 dark:text-dk-text">
|
||||
{{ customer.title ? t(`customer.salutation_${customer.title.toLowerCase()}`) : '-' }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-500 dark:text-dk-subtle">{{ t('customer.created_at') }}</label>
|
||||
<p class="text-gray-900 dark:text-dk-text">{{ formatDate(customer.created_at) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-500 dark:text-dk-subtle">{{ t('customer.created_by') }}</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
:src="usersStore.getAvatarUrlFromUserID(customer.created_by)"
|
||||
class="h-6 w-6 rounded-full"
|
||||
alt="avatar"
|
||||
/>
|
||||
<span class="text-gray-900 dark:text-dk-text">
|
||||
{{ usersStore.getUsernameFromUserID(customer.created_by) || 'ID: ' + customer.created_by }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phones Card -->
|
||||
<div v-if="phones.length > 0" class="rounded-lg border border-gray-200 bg-white p-6 dark:border-dk-muted dark:bg-dk-card">
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-dk-text">{{ t('customer.phones') }}</h2>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="phone in phones"
|
||||
:key="phone.id"
|
||||
class="flex items-center justify-between rounded-lg border border-gray-100 bg-gray-50 p-3 dark:border-dk-muted dark:bg-dk-muted"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-gray-500 dark:text-dk-subtle">+{{ phone.prefix }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-dk-text">{{ phone.phone }}</span>
|
||||
<span
|
||||
class="rounded px-2 py-0.5 text-xs"
|
||||
:class="phone.is_primary ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' : 'bg-gray-200 text-gray-600 dark:bg-dk-muted dark:text-dk-subtle'"
|
||||
>
|
||||
{{ getLabelText(phone.label) }}
|
||||
<span v-if="phone.is_primary" class="ml-1">({{ t('customer.primary') }})</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Emails Card -->
|
||||
<div v-if="emails.length > 0" class="rounded-lg border border-gray-200 bg-white p-6 dark:border-dk-muted dark:bg-dk-card">
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-dk-text">{{ t('customer.emails') }}</h2>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="email in emails"
|
||||
:key="email.id"
|
||||
class="flex items-center justify-between rounded-lg border border-gray-100 bg-gray-50 p-3 dark:border-dk-muted dark:bg-dk-muted"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-medium text-gray-900 dark:text-dk-text">{{ email.email }}</span>
|
||||
<span
|
||||
class="rounded px-2 py-0.5 text-xs"
|
||||
:class="email.is_primary ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' : 'bg-gray-200 text-gray-600 dark:bg-dk-muted dark:text-dk-subtle'"
|
||||
>
|
||||
{{ getLabelText(email.label) }}
|
||||
<span v-if="email.is_primary" class="ml-1">({{ t('customer.primary') }})</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Companies Card -->
|
||||
<div v-if="companies.length > 0" class="rounded-lg border border-gray-200 bg-white p-6 dark:border-dk-muted dark:bg-dk-card">
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-dk-text">{{ t('customer.companies') }}</h2>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="company in companies"
|
||||
:key="company.id"
|
||||
class="rounded-lg border border-gray-100 bg-gray-50 p-3 dark:border-dk-muted dark:bg-dk-muted"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium text-gray-900 dark:text-dk-text">{{ company.company_name }}</span>
|
||||
<span
|
||||
v-if="company.is_primary"
|
||||
class="rounded px-2 py-0.5 text-xs bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
||||
>
|
||||
{{ t('customer.primary') }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="company.department || company.position" class="mt-1 text-sm text-gray-500 dark:text-dk-subtle">
|
||||
<span v-if="company.department">{{ company.department }}</span>
|
||||
<span v-if="company.department && company.position"> · </span>
|
||||
<span v-if="company.position">{{ company.position }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Not Found -->
|
||||
<div v-else class="rounded-lg border border-gray-200 bg-white p-12 text-center dark:border-dk-muted dark:bg-dk-card">
|
||||
<p class="text-gray-500 dark:text-dk-subtle">{{ t('customer.not_found') }}</p>
|
||||
<button
|
||||
@click="goBack"
|
||||
class="mt-4 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
{{ t('common.back') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<AppToast
|
||||
v-if="toast.show"
|
||||
:message="toast.message"
|
||||
:type="toast.type"
|
||||
@close="toast.show = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import CustomerFormModal from './CustomerFormModal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const customerId = ref(null)
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(() => {
|
||||
const id = route.params.id
|
||||
if (!id) {
|
||||
router.push('/customer')
|
||||
return
|
||||
}
|
||||
customerId.value = parseInt(id)
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
function onClose() {
|
||||
router.push('/customer')
|
||||
}
|
||||
|
||||
function onSubmitted() {
|
||||
router.push(`/customer/detail/${customerId.value}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!loading && customerId">
|
||||
<CustomerFormModal
|
||||
:title="t('customer.edit_title')"
|
||||
:customer="{ id: customerId }"
|
||||
@close="onClose"
|
||||
@submit="onSubmitted"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,267 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { customerApi } from '@/api/customer'
|
||||
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
customer: Object,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'submit'])
|
||||
const { t } = useI18n()
|
||||
|
||||
const form = ref({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
title: 'Unit',
|
||||
phones: [{ prefix: '853', phone: '', label: 'mobile', is_primary: true }],
|
||||
emails: [{ email: '', label: 'work', is_primary: true }],
|
||||
companies: [{ company_name: '', department: '', position: '', is_primary: true }],
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const errors = ref({})
|
||||
|
||||
const titleOptions = ['Unit', 'Mr', 'Ms']
|
||||
const phoneLabels = ['mobile', 'work', 'home', 'other']
|
||||
const emailLabels = ['work', 'personal', 'other']
|
||||
const prefixOptions = ['853', '852', '86']
|
||||
|
||||
// 编辑模式填充数据
|
||||
watch(() => props.customer, async (customer) => {
|
||||
if (customer) {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await customerApi.get({ id: customer.id })
|
||||
if (res.errCode === 0) {
|
||||
const data = res.data
|
||||
form.value = {
|
||||
first_name: data.customer.first_name || '',
|
||||
last_name: data.customer.last_name || '',
|
||||
title: data.customer.title || 'Unit',
|
||||
phones: data.phones?.length > 0 ? data.phones.map(p => ({
|
||||
prefix: p.prefix || '853',
|
||||
phone: p.phone || '',
|
||||
label: p.label || 'mobile',
|
||||
is_primary: p.is_primary || false,
|
||||
})) : [{ prefix: '853', phone: '', label: 'mobile', is_primary: true }],
|
||||
emails: data.emails?.length > 0 ? data.emails.map(e => ({
|
||||
email: e.email || '',
|
||||
label: e.label || 'work',
|
||||
is_primary: e.is_primary || false,
|
||||
})) : [{ email: '', label: 'work', is_primary: true }],
|
||||
companies: data.companies?.length > 0 ? data.companies.map(c => ({
|
||||
company_name: c.company_name || '',
|
||||
department: c.department || '',
|
||||
position: c.position || '',
|
||||
is_primary: c.is_primary || false,
|
||||
})) : [{ company_name: '', department: '', position: '', is_primary: true }],
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function addPhone() {
|
||||
form.value.phones.push({ prefix: '853', phone: '', label: 'mobile', is_primary: false })
|
||||
}
|
||||
|
||||
function removePhone(index) {
|
||||
form.value.phones.splice(index, 1)
|
||||
if (form.value.phones.length === 1) {
|
||||
form.value.phones[0].is_primary = true
|
||||
}
|
||||
}
|
||||
|
||||
function setPrimaryPhone(index) {
|
||||
form.value.phones.forEach((p, i) => (p.is_primary = i === index))
|
||||
}
|
||||
|
||||
function addEmail() {
|
||||
form.value.emails.push({ email: '', label: 'work', is_primary: false })
|
||||
}
|
||||
|
||||
function removeEmail(index) {
|
||||
form.value.emails.splice(index, 1)
|
||||
if (form.value.emails.length === 1) {
|
||||
form.value.emails[0].is_primary = true
|
||||
}
|
||||
}
|
||||
|
||||
function setPrimaryEmail(index) {
|
||||
form.value.emails.forEach((e, i) => (e.is_primary = i === index))
|
||||
}
|
||||
|
||||
function addCompany() {
|
||||
form.value.companies.push({ company_name: '', department: '', position: '', is_primary: false })
|
||||
}
|
||||
|
||||
function removeCompany(index) {
|
||||
form.value.companies.splice(index, 1)
|
||||
if (form.value.companies.length === 1) {
|
||||
form.value.companies[0].is_primary = true
|
||||
}
|
||||
}
|
||||
|
||||
function setPrimaryCompany(index) {
|
||||
form.value.companies.forEach((c, i) => (c.is_primary = i === index))
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
errors.value = {}
|
||||
|
||||
// 基本验证(姓必填,名可选)
|
||||
if (!form.value.last_name?.trim()) {
|
||||
errors.value.last_name = t('validation.required')
|
||||
}
|
||||
|
||||
// 过滤空数据
|
||||
const payload = {
|
||||
...form.value,
|
||||
phones: form.value.phones.filter(p => p.phone.trim()),
|
||||
emails: form.value.emails.filter(e => e.email.trim()),
|
||||
companies: form.value.companies.filter(c => c.company_name.trim()),
|
||||
}
|
||||
|
||||
if (Object.keys(errors.value).length > 0) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const api = props.customer ? customerApi.update : customerApi.add
|
||||
const params = props.customer ? { ...payload, id: props.customer.id } : payload
|
||||
const res = await api(params)
|
||||
if (res.errCode === 0) {
|
||||
emit('submit')
|
||||
} else {
|
||||
errors.value.submit = res.errMsg || t('message.error')
|
||||
}
|
||||
} catch {
|
||||
errors.value.submit = t('message.error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div class="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-lg bg-white p-6 dark:bg-dk-card">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-dk-text">{{ title }}</h2>
|
||||
<button @click="$emit('close')" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit" class="space-y-4">
|
||||
<!-- Basic Info -->
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-dk-subtle">{{ t('customer.title') }}</label>
|
||||
<select v-model="form.title" class="w-full rounded-lg border border-gray-300 px-3 py-2 dark:border-dk-muted dark:bg-dk-base dark:text-dk-text">
|
||||
<option v-for="opt in titleOptions" :key="opt" :value="opt">{{ t(`customer.salutation_${opt.toLowerCase()}`) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-dk-subtle">{{ t('customer.last_name') }} *</label>
|
||||
<input v-model="form.last_name" type="text" class="w-full rounded-lg border border-gray-300 px-3 py-2 dark:border-dk-muted dark:bg-dk-base dark:text-dk-text" />
|
||||
<p v-if="errors.last_name" class="mt-1 text-xs text-red-500">{{ errors.last_name }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-dk-subtle">{{ t('customer.first_name') }}</label>
|
||||
<input v-model="form.first_name" type="text" class="w-full rounded-lg border border-gray-300 px-3 py-2 dark:border-dk-muted dark:bg-dk-base dark:text-dk-text" />
|
||||
<p v-if="errors.first_name" class="mt-1 text-xs text-red-500">{{ errors.first_name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phones -->
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-dk-subtle">{{ t('customer.phones') }}</label>
|
||||
<button type="button" @click="addPhone" class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">+ {{ t('customer.add_phone') }}</button>
|
||||
</div>
|
||||
<div v-for="(phone, index) in form.phones" :key="index" class="mb-2 flex items-center gap-2">
|
||||
<select v-model="phone.prefix" class="w-20 rounded-lg border border-gray-300 px-2 py-2 dark:border-dk-muted dark:bg-dk-base dark:text-dk-text">
|
||||
<option v-for="p in prefixOptions" :key="p" :value="p">+{{ p }}</option>
|
||||
</select>
|
||||
<input v-model="phone.phone" type="text" :placeholder="t('customer.phone_number')" class="flex-1 rounded-lg border border-gray-300 px-3 py-2 dark:border-dk-muted dark:bg-dk-base dark:text-dk-text" />
|
||||
<select v-model="phone.label" class="w-24 rounded-lg border border-gray-300 px-2 py-2 dark:border-dk-muted dark:bg-dk-base dark:text-dk-text">
|
||||
<option v-for="l in phoneLabels" :key="l" :value="l">{{ t(`customer.label_${l}`) }}</option>
|
||||
</select>
|
||||
<button type="button" @click="setPrimaryPhone(index)" :class="['rounded px-2 py-1 text-xs', phone.is_primary ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' : 'text-gray-500']">
|
||||
{{ phone.is_primary ? t('customer.primary') : t('customer.set_primary') }}
|
||||
</button>
|
||||
<button v-if="form.phones.length > 1" type="button" @click="removePhone(index)" class="text-red-500 hover:text-red-600">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Emails -->
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-dk-subtle">{{ t('customer.emails') }}</label>
|
||||
<button type="button" @click="addEmail" class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">+ {{ t('customer.add_email') }}</button>
|
||||
</div>
|
||||
<div v-for="(email, index) in form.emails" :key="index" class="mb-2 flex items-center gap-2">
|
||||
<input v-model="email.email" type="email" :placeholder="t('customer.email_address')" class="flex-1 rounded-lg border border-gray-300 px-3 py-2 dark:border-dk-muted dark:bg-dk-base dark:text-dk-text" />
|
||||
<select v-model="email.label" class="w-24 rounded-lg border border-gray-300 px-2 py-2 dark:border-dk-muted dark:bg-dk-base dark:text-dk-text">
|
||||
<option v-for="l in emailLabels" :key="l" :value="l">{{ t(`customer.label_${l}`) }}</option>
|
||||
</select>
|
||||
<button type="button" @click="setPrimaryEmail(index)" :class="['rounded px-2 py-1 text-xs', email.is_primary ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' : 'text-gray-500']">
|
||||
{{ email.is_primary ? t('customer.primary') : t('customer.set_primary') }}
|
||||
</button>
|
||||
<button v-if="form.emails.length > 1" type="button" @click="removeEmail(index)" class="text-red-500 hover:text-red-600">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Companies -->
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-dk-subtle">{{ t('customer.companies') }}</label>
|
||||
<button type="button" @click="addCompany" class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">+ {{ t('customer.add_company') }}</button>
|
||||
</div>
|
||||
<div v-for="(company, index) in form.companies" :key="index" class="mb-3 rounded-lg border border-gray-200 p-3 dark:border-dk-muted">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<button type="button" @click="setPrimaryCompany(index)" :class="['rounded px-2 py-1 text-xs', company.is_primary ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' : 'text-gray-500']">
|
||||
{{ company.is_primary ? t('customer.primary') : t('customer.set_primary') }}
|
||||
</button>
|
||||
<button v-if="form.companies.length > 1" type="button" @click="removeCompany(index)" class="text-red-500 hover:text-red-600">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<input v-model="company.company_name" type="text" :placeholder="t('customer.company_name')" class="rounded-lg border border-gray-300 px-3 py-2 dark:border-dk-muted dark:bg-dk-base dark:text-dk-text" />
|
||||
<input v-model="company.department" type="text" :placeholder="t('customer.department')" class="rounded-lg border border-gray-300 px-3 py-2 dark:border-dk-muted dark:bg-dk-base dark:text-dk-text" />
|
||||
<input v-model="company.position" type="text" :placeholder="t('customer.position')" class="rounded-lg border border-gray-300 px-3 py-2 dark:border-dk-muted dark:bg-dk-base dark:text-dk-text" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="errors.submit" class="text-sm text-red-500">{{ errors.submit }}</p>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button type="button" @click="$emit('close')" class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-dk-muted dark:text-dk-text">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button type="submit" :disabled="loading" class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50">
|
||||
{{ loading ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,276 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { customerApi } from '@/api/customer'
|
||||
import AppToast from '@/components/AppToast.vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import CustomerFormModal from './CustomerFormModal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const customers = ref([])
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
|
||||
const showAddModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showDeleteConfirm = ref(false)
|
||||
const editingCustomer = ref(null)
|
||||
const deletingCustomer = ref(null)
|
||||
|
||||
const toast = ref({ show: false, message: '', type: 'success' })
|
||||
|
||||
// 客户列表
|
||||
async function fetchCustomers() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await customerApi.list({
|
||||
page: currentPage.value,
|
||||
page_size: pageSize.value,
|
||||
search: searchQuery.value,
|
||||
})
|
||||
if (res.errCode === 0) {
|
||||
customers.value = res.data.customers || []
|
||||
total.value = res.data.total || 0
|
||||
}
|
||||
} catch {
|
||||
showToast(t('message.error'), 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
currentPage.value = 1
|
||||
fetchCustomers()
|
||||
}
|
||||
|
||||
function onPageChange(page) {
|
||||
currentPage.value = page
|
||||
fetchCustomers()
|
||||
}
|
||||
|
||||
// 新增客户
|
||||
function openAddModal() {
|
||||
showAddModal.value = true
|
||||
}
|
||||
|
||||
function onCustomerAdded() {
|
||||
showAddModal.value = false
|
||||
showToast(t('message.add_success'), 'success')
|
||||
fetchCustomers()
|
||||
}
|
||||
|
||||
// 编辑客户
|
||||
function openEditModal(customer) {
|
||||
editingCustomer.value = customer
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
function onCustomerUpdated() {
|
||||
showEditModal.value = false
|
||||
editingCustomer.value = null
|
||||
showToast(t('message.update_success'), 'success')
|
||||
fetchCustomers()
|
||||
}
|
||||
|
||||
// 删除客户
|
||||
function confirmDelete(customer) {
|
||||
deletingCustomer.value = customer
|
||||
showDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!deletingCustomer.value) return
|
||||
try {
|
||||
const res = await customerApi.delete({ id: deletingCustomer.value.id })
|
||||
if (res.errCode === 0) {
|
||||
showToast(t('message.delete_success'), 'success')
|
||||
fetchCustomers()
|
||||
} else {
|
||||
showToast(res.errMsg || t('message.error'), 'error')
|
||||
}
|
||||
} catch {
|
||||
showToast(t('message.error'), 'error')
|
||||
} finally {
|
||||
showDeleteConfirm.value = false
|
||||
deletingCustomer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message, type = 'success') {
|
||||
toast.value = { show: true, message, type }
|
||||
setTimeout(() => (toast.value.show = false), 3000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchCustomers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 p-6 dark:bg-dk-base">
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-dk-text">{{ t('customer.title') }}</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-dk-subtle">{{ t('customer.subtitle') }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="openAddModal"
|
||||
class="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{{ t('customer.add') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="mb-4 flex gap-3">
|
||||
<div class="relative flex-1">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
@keyup.enter="onSearch"
|
||||
type="text"
|
||||
:placeholder="t('customer.search_placeholder')"
|
||||
class="w-full rounded-lg border border-gray-300 px-4 py-2 pl-10 text-sm focus:border-blue-500 focus:outline-none dark:border-dk-muted dark:bg-dk-card dark:text-dk-text"
|
||||
/>
|
||||
<svg class="absolute left-3 top-2.5 h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<button
|
||||
@click="onSearch"
|
||||
class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-dk-muted dark:bg-dk-card dark:text-dk-text"
|
||||
>
|
||||
{{ t('common.search') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Customer List -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white dark:border-dk-muted dark:bg-dk-card">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="border-b border-gray-200 bg-gray-50 dark:border-dk-muted dark:bg-dk-muted">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium text-gray-700 dark:text-dk-subtle">{{ t('customer.name') }}</th>
|
||||
<th class="px-4 py-3 font-medium text-gray-700 dark:text-dk-subtle">{{ t('customer.salutation') }}</th>
|
||||
<th class="px-4 py-3 font-medium text-gray-700 dark:text-dk-subtle">{{ t('customer.phone') }}</th>
|
||||
<th class="px-4 py-3 font-medium text-gray-700 dark:text-dk-subtle">{{ t('customer.email') }}</th>
|
||||
<th class="px-4 py-3 font-medium text-gray-700 dark:text-dk-subtle">{{ t('customer.company') }}</th>
|
||||
<th class="px-4 py-3 font-medium text-gray-700 dark:text-dk-subtle">{{ t('customer.created_at') }}</th>
|
||||
<th class="px-4 py-3 font-medium text-gray-700 dark:text-dk-subtle">{{ t('common.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="customer in customers"
|
||||
:key="customer.id"
|
||||
class="border-b border-gray-100 hover:bg-gray-50 dark:border-dk-muted dark:hover:bg-dk-muted"
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<router-link
|
||||
:to="`/customer/detail/${customer.id}`"
|
||||
class="font-medium text-blue-600 hover:text-blue-700 hover:underline dark:text-blue-400"
|
||||
>
|
||||
{{ (customer.last_name || '') + (customer.first_name ? ' ' + customer.first_name : '') }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-dk-subtle">{{ customer.title ? t(`customer.salutation_${customer.title.toLowerCase()}`) : '-' }}</td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-dk-subtle">{{ customer.primary_phone || '-' }}</td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-dk-subtle">{{ customer.primary_email || '-' }}</td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-dk-subtle">{{ customer.primary_company || '-' }}</td>
|
||||
<td class="px-4 py-3 text-gray-500 dark:text-dk-subtle">{{ new Date(customer.created_at).toLocaleString() }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="customer.edit"
|
||||
@click="openEditModal(customer)"
|
||||
class="text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||
>
|
||||
{{ t('common.edit') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="customer.edit"
|
||||
@click="confirmDelete(customer)"
|
||||
class="text-red-600 hover:text-red-700 dark:text-red-400"
|
||||
>
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="customers.length === 0 && !loading">
|
||||
<td colspan="7" class="px-4 py-8 text-center text-gray-500 dark:text-dk-subtle">
|
||||
{{ t('customer.no_data') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="total > pageSize" class="flex items-center justify-between border-t border-gray-200 px-4 py-3 dark:border-dk-muted">
|
||||
<div class="text-sm text-gray-500 dark:text-dk-subtle">
|
||||
{{ t('common.total') }} {{ total }} {{ t('common.items') }}
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
v-for="page in Math.ceil(total / pageSize)"
|
||||
:key="page"
|
||||
@click="onPageChange(page)"
|
||||
:class="[
|
||||
'rounded px-3 py-1 text-sm',
|
||||
currentPage === page
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 dark:border-dk-muted dark:bg-dk-card dark:text-dk-text',
|
||||
]"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Modal -->
|
||||
<CustomerFormModal
|
||||
v-if="showAddModal"
|
||||
:title="t('customer.add_title')"
|
||||
@close="showAddModal = false"
|
||||
@submit="onCustomerAdded"
|
||||
/>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<CustomerFormModal
|
||||
v-if="showEditModal"
|
||||
:title="t('customer.edit_title')"
|
||||
:customer="editingCustomer"
|
||||
@close="showEditModal = false"
|
||||
@submit="onCustomerUpdated"
|
||||
/>
|
||||
|
||||
<!-- Delete Confirm -->
|
||||
<ConfirmDialog
|
||||
v-model="showDeleteConfirm"
|
||||
:title="t('customer.delete_title')"
|
||||
:message="t('customer.delete_confirm')"
|
||||
@confirm="doDelete"
|
||||
danger
|
||||
/>
|
||||
|
||||
<!-- Toast -->
|
||||
<AppToast
|
||||
v-if="toast.show"
|
||||
:message="toast.message"
|
||||
:type="toast.type"
|
||||
@close="toast.show = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { useRouter } from 'vue-router'
|
||||
import UsersTab from '@/views/sysadmin/UsersTab.vue'
|
||||
import GroupsTab from '@/views/sysadmin/GroupsTab.vue'
|
||||
import LogsTab from '@/views/sysadmin/LogsTab.vue'
|
||||
@@ -11,6 +12,7 @@ import LogsTab from '@/views/sysadmin/LogsTab.vue'
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
const usersStore = useUsersStore()
|
||||
const router = useRouter()
|
||||
|
||||
const activeTab = ref('users')
|
||||
const sysAdmins = ref([])
|
||||
@@ -25,6 +27,7 @@ const tabs = [
|
||||
{ id: 'users', label: t('sysadmin.tab_users') },
|
||||
{ id: 'groups', label: t('sysadmin.tab_groups') },
|
||||
{ id: 'logs', label: t('sysadmin.tab_logs') },
|
||||
{ id: 'customer', label: t('customer.title'), to: '/customer' },
|
||||
]
|
||||
|
||||
async function fetchSysAdmins() {
|
||||
@@ -71,10 +74,10 @@ onMounted(() => {
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
@click="activeTab = tab.id"
|
||||
@click="tab.to ? router.push(tab.to) : (activeTab = tab.id)"
|
||||
:class="[
|
||||
'border-b-2 px-1 pb-3 text-sm font-medium transition-colors',
|
||||
activeTab === tab.id
|
||||
activeTab === tab.id && !tab.to
|
||||
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-dk-subtle dark:hover:text-dk-text',
|
||||
]"
|
||||
|
||||
@@ -13,8 +13,10 @@ import { usePageTitle } from '@/composables/usePageTitle'
|
||||
import { useValidation } from '@/composables'
|
||||
import { workOrderApi } from '@/api/work_order'
|
||||
import { warehouseApi } from '@/api/warehouse'
|
||||
import { customerApi } from '@/api/customer'
|
||||
import useDropzone from '@/components/useDropzone.vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import { IconPackage, IconUser, IconX } from '@tabler/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -33,14 +35,22 @@ const pageLoading = ref(false)
|
||||
const pageError = ref('')
|
||||
const showDeleteConfirm = ref(false)
|
||||
|
||||
// ==================== 关联物品搜索 ====================
|
||||
// ==================== 关联物品搜索(多选) ====================
|
||||
const itemSearchQuery = ref('')
|
||||
const itemSearchResults = ref([])
|
||||
const itemSearchLoading = ref(false)
|
||||
const showItemDropdown = ref(false)
|
||||
const selectedItem = ref(null)
|
||||
const selectedItems = ref([])
|
||||
|
||||
// ==================== 关联客户搜索(多选) ====================
|
||||
const customerSearchQuery = ref('')
|
||||
const customerSearchResults = ref([])
|
||||
const customerSearchLoading = ref(false)
|
||||
const showCustomerDropdown = ref(false)
|
||||
const selectedCustomers = ref([])
|
||||
|
||||
let searchTimer = null
|
||||
let customerSearchTimer = null
|
||||
|
||||
function onItemSearchInput() {
|
||||
clearTimeout(searchTimer)
|
||||
@@ -80,21 +90,68 @@ function onItemSearchInput() {
|
||||
}
|
||||
|
||||
function selectItem(item) {
|
||||
selectedItem.value = item
|
||||
linkedItemId.value = item.ID
|
||||
// 检查是否已选中
|
||||
if (!selectedItems.value.find(i => i.ID === item.ID)) {
|
||||
selectedItems.value.push(item)
|
||||
}
|
||||
itemSearchQuery.value = ''
|
||||
itemSearchResults.value = []
|
||||
showItemDropdown.value = false
|
||||
}
|
||||
|
||||
function clearSelectedItem() {
|
||||
selectedItem.value = null
|
||||
linkedItemId.value = null
|
||||
function removeSelectedItem(itemId) {
|
||||
selectedItems.value = selectedItems.value.filter(i => i.ID !== itemId)
|
||||
}
|
||||
|
||||
// ==================== 关联客户搜索 ====================
|
||||
function onCustomerSearchInput() {
|
||||
clearTimeout(customerSearchTimer)
|
||||
customerSearchTimer = setTimeout(async () => {
|
||||
customerSearchLoading.value = true
|
||||
showCustomerDropdown.value = true
|
||||
try {
|
||||
let res
|
||||
if (customerSearchQuery.value.trim().length > 0) {
|
||||
res = await customerApi.list({ search: customerSearchQuery.value.trim(), page: 1, page_size: 10 })
|
||||
if (res.errCode === 0 && res.data) {
|
||||
customerSearchResults.value = (res.data.customers || []).slice(0, 10)
|
||||
} else {
|
||||
customerSearchResults.value = []
|
||||
}
|
||||
} else {
|
||||
res = await customerApi.list({ page: 1, page_size: 5 })
|
||||
if (res.errCode === 0 && res.data) {
|
||||
customerSearchResults.value = (res.data.customers || []).sort((a, b) => b.ID - a.ID)
|
||||
} else {
|
||||
customerSearchResults.value = []
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
customerSearchResults.value = []
|
||||
} finally {
|
||||
customerSearchLoading.value = false
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function selectCustomer(customer) {
|
||||
// 检查是否已选中
|
||||
if (!selectedCustomers.value.find(c => c.id === customer.id)) {
|
||||
selectedCustomers.value.push(customer)
|
||||
}
|
||||
customerSearchQuery.value = ''
|
||||
customerSearchResults.value = []
|
||||
showCustomerDropdown.value = false
|
||||
}
|
||||
|
||||
function removeSelectedCustomer(customerId) {
|
||||
selectedCustomers.value = selectedCustomers.value.filter(c => c.id !== customerId)
|
||||
}
|
||||
|
||||
function handleClickOutside(e) {
|
||||
if (!e.target.closest('.item-search-wrapper')) {
|
||||
if (!e.target.closest('.item-search-wrapper') && !e.target.closest('.customer-search-wrapper')) {
|
||||
showItemDropdown.value = false
|
||||
showCustomerDropdown.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +174,8 @@ function getPhotoHashes() {
|
||||
}
|
||||
|
||||
// ==================== 加载编辑数据 ====================
|
||||
const linkedItemId = ref(null)
|
||||
const linkedItemIds = ref([])
|
||||
const linkedCustomerIds = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
// 新增模式:检查预填数据
|
||||
@@ -134,8 +192,13 @@ onMounted(async () => {
|
||||
try {
|
||||
const itemRes = await warehouseApi.getItem(prefill.itemId)
|
||||
if (itemRes.errCode === 0 && itemRes.data) {
|
||||
selectedItem.value = itemRes.data.item
|
||||
linkedItemId.value = prefill.itemId
|
||||
const item = itemRes.data.item
|
||||
if (!selectedItems.value.find(i => i.ID === item.ID)) {
|
||||
selectedItems.value.push(item)
|
||||
}
|
||||
if (!linkedItemIds.value.includes(prefill.itemId)) {
|
||||
linkedItemIds.value.push(prefill.itemId)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 获取物品详情失败,忽略
|
||||
@@ -159,7 +222,7 @@ onMounted(async () => {
|
||||
return
|
||||
}
|
||||
|
||||
const { order, photos } = res.data
|
||||
const { order, photos, linkedItems, linkedCustomers } = res.data
|
||||
form.title = order.Title ?? ''
|
||||
form.description = order.Description ?? ''
|
||||
|
||||
@@ -168,6 +231,28 @@ onMounted(async () => {
|
||||
if (photos && photos.length > 0) {
|
||||
form.photos = photos
|
||||
}
|
||||
|
||||
// 回填关联物品
|
||||
if (linkedItems && linkedItems.length > 0) {
|
||||
selectedItems.value = linkedItems.map(item => ({
|
||||
ID: item.ID,
|
||||
Name: item.Name,
|
||||
SerialNumber: item.SerialNumber,
|
||||
ContainerID: item.ContainerID,
|
||||
}))
|
||||
linkedItemIds.value = linkedItems.map(item => item.ID)
|
||||
}
|
||||
|
||||
// 回填关联客户
|
||||
if (linkedCustomers && linkedCustomers.length > 0) {
|
||||
selectedCustomers.value = linkedCustomers.map(c => ({
|
||||
id: c.id,
|
||||
first_name: c.first_name,
|
||||
last_name: c.last_name,
|
||||
primary_phone: c.primary_phone,
|
||||
}))
|
||||
linkedCustomerIds.value = linkedCustomers.map(c => c.id)
|
||||
}
|
||||
} catch {
|
||||
pageError.value = t('work_order.not_found')
|
||||
} finally {
|
||||
@@ -213,13 +298,16 @@ async function handleSubmit() {
|
||||
title: form.title,
|
||||
description: form.description,
|
||||
photos,
|
||||
item_ids: selectedItems.value.map(i => i.ID),
|
||||
customer_ids: selectedCustomers.value.map(c => c.id),
|
||||
})
|
||||
} else {
|
||||
res = await workOrderApi.add({
|
||||
title: form.title,
|
||||
description: form.description,
|
||||
photos,
|
||||
item_id: linkedItemId.value,
|
||||
item_ids: selectedItems.value.map(i => i.ID),
|
||||
customer_ids: selectedCustomers.value.map(c => c.id),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -331,32 +419,33 @@ async function handleSubmit() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 关联物品搜索(仅新增模式) -->
|
||||
<div v-if="!isEdit">
|
||||
<!-- 关联物品搜索(多选) -->
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('work_order.linked_item') }}
|
||||
{{ t('work_order.linked_items') }}
|
||||
</label>
|
||||
|
||||
<!-- 已选择物品显示 -->
|
||||
<div v-if="selectedItem" class="flex items-center gap-2 rounded-lg border border-green-300 bg-green-50 px-3 py-2 dark:border-green-700 dark:bg-green-900/30">
|
||||
<svg class="h-4 w-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8 4-8-4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
<span class="text-sm text-green-800 dark:text-green-200">
|
||||
{{ selectedItem.Name }}{{ selectedItem.SerialNumber ? ' - ' + selectedItem.SerialNumber : '' }}
|
||||
</span>
|
||||
<span class="ml-auto text-xs text-green-600 dark:text-green-400">{{ t('work_order.linked_item_selected') }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-2 text-xs text-gray-500 hover:text-red-500 dark:text-gray-400 dark:hover:text-red-400"
|
||||
@click="clearSelectedItem"
|
||||
<!-- 已选择物品列表 -->
|
||||
<div v-if="selectedItems.length > 0" class="mb-2 flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="item in selectedItems"
|
||||
:key="item.ID"
|
||||
class="inline-flex items-center gap-1 rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700 dark:border-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
|
||||
>
|
||||
{{ t('work_order.clear_linked_item') }}
|
||||
</button>
|
||||
<IconPackage :size="12" />
|
||||
{{ item.Name }}{{ item.SerialNumber ? ' - ' + item.SerialNumber : '' }}
|
||||
<button
|
||||
type="button"
|
||||
class="ml-0.5 rounded-full p-0.5 transition-colors hover:bg-blue-200 dark:hover:bg-blue-800"
|
||||
@click="removeSelectedItem(item.ID)"
|
||||
>
|
||||
<IconX :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div v-else class="item-search-wrapper relative">
|
||||
<div class="item-search-wrapper relative">
|
||||
<input
|
||||
v-model="itemSearchQuery"
|
||||
type="text"
|
||||
@@ -397,6 +486,77 @@ async function handleSubmit() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关联客户搜索(多选) -->
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('work_order.linked_customers') }}
|
||||
</label>
|
||||
|
||||
<!-- 已选择客户列表 -->
|
||||
<div v-if="selectedCustomers.length > 0" class="mb-2 flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="customer in selectedCustomers"
|
||||
:key="customer.id"
|
||||
class="inline-flex items-center gap-1 rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700 dark:border-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
|
||||
>
|
||||
<IconUser :size="12" />
|
||||
{{ (customer.last_name || '') + (customer.first_name ? ' ' + customer.first_name : '') }}
|
||||
<button
|
||||
type="button"
|
||||
class="ml-0.5 rounded-full p-0.5 transition-colors hover:bg-blue-200 dark:hover:bg-blue-800"
|
||||
@click="removeSelectedCustomer(customer.id)"
|
||||
>
|
||||
<IconX :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div class="customer-search-wrapper relative">
|
||||
<input
|
||||
v-model="customerSearchQuery"
|
||||
type="text"
|
||||
:placeholder="t('work_order.linked_customer_placeholder')"
|
||||
class="w-full rounded-lg border border-gray-300 bg-white px-3.5 py-2 text-sm outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-dk-muted dark:bg-dk-base dark:text-white"
|
||||
@input="onCustomerSearchInput"
|
||||
@focus="customerSearchQuery || onCustomerSearchInput()"
|
||||
/>
|
||||
<!-- 下拉结果 -->
|
||||
<div
|
||||
v-if="showCustomerDropdown && customerSearchResults.length > 0"
|
||||
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-dk-muted dark:bg-dk-card"
|
||||
>
|
||||
<div
|
||||
v-for="customer in customerSearchResults"
|
||||
:key="customer.ID"
|
||||
class="cursor-pointer px-3 py-2 text-sm hover:bg-blue-50 dark:hover:bg-blue-900/30"
|
||||
@click="selectCustomer(customer)"
|
||||
>
|
||||
<div class="font-medium text-gray-900 dark:text-white">
|
||||
{{ (customer.last_name || '') + (customer.first_name ? ' ' + customer.first_name : '') }}
|
||||
</div>
|
||||
<div v-if="customer.primary_phone || customer.primary_company" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ customer.primary_phone }}{{ customer.primary_phone && customer.primary_company ? ' · ' : '' }}{{ customer.primary_company }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 加载中 -->
|
||||
<div
|
||||
v-if="showCustomerDropdown && customerSearchLoading"
|
||||
class="absolute z-10 mt-1 w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-500 shadow-lg dark:border-dk-muted dark:bg-dk-card"
|
||||
>
|
||||
{{ t('message.loading') }}
|
||||
</div>
|
||||
<!-- 无结果 -->
|
||||
<div
|
||||
v-if="showCustomerDropdown && !customerSearchLoading && customerSearchResults.length === 0 && customerSearchQuery.trim().length > 0"
|
||||
class="absolute z-10 mt-1 w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-500 shadow-lg dark:border-dk-muted dark:bg-dk-card"
|
||||
>
|
||||
{{ t('work_order.linked_customer_not_found') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图片上传 -->
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
IconSearch,
|
||||
IconExternalLink,
|
||||
IconPackage,
|
||||
IconUser,
|
||||
} from '@tabler/icons-vue'
|
||||
|
||||
usePageTitle('work_order.detail_title')
|
||||
@@ -34,6 +35,7 @@ const order = ref(null)
|
||||
const photos = ref([])
|
||||
const commits = ref([])
|
||||
const linkedItems = ref([])
|
||||
const linkedCustomers = ref([])
|
||||
const canModify = ref(false)
|
||||
const canCommit = ref(false)
|
||||
const loading = ref(true)
|
||||
@@ -139,6 +141,7 @@ async function fetchOrder() {
|
||||
photos.value = data.photos ?? []
|
||||
commits.value = data.commits ?? []
|
||||
linkedItems.value = data.linkedItems ?? []
|
||||
linkedCustomers.value = data.linkedCustomers ?? []
|
||||
// 初始化进度提交状态为当前状态
|
||||
if (order.value?.CurrentStatus) {
|
||||
commitStatus.value = order.value.CurrentStatus
|
||||
@@ -300,6 +303,19 @@ function getPurchaseStatusClass(status) {
|
||||
return map[status] || 'bg-gray-100 text-gray-600'
|
||||
}
|
||||
|
||||
// 采购订单气泡样式(整个气泡按状态染色)
|
||||
function getPoBubbleClass(status) {
|
||||
const map = {
|
||||
pending: 'border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 dark:border-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 dark:hover:bg-yellow-900/50',
|
||||
ordered: 'border-blue-200 bg-blue-50 text-blue-700 hover:bg-blue-100 dark:border-blue-800 dark:bg-blue-900/30 dark:text-blue-300 dark:hover:bg-blue-900/50',
|
||||
arrived: 'border-purple-200 bg-purple-50 text-purple-700 hover:bg-purple-100 dark:border-purple-800 dark:bg-purple-900/30 dark:text-purple-300 dark:hover:bg-purple-900/50',
|
||||
received: 'border-green-200 bg-green-50 text-green-700 hover:bg-green-100 dark:border-green-800 dark:bg-green-900/30 dark:text-green-300 dark:hover:bg-green-900/50',
|
||||
lost: 'border-red-200 bg-red-50 text-red-700 hover:bg-red-100 dark:border-red-800 dark:bg-red-900/30 dark:text-red-300 dark:hover:bg-red-900/50',
|
||||
returned: 'border-gray-200 bg-gray-50 text-gray-500 hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800/30 dark:text-gray-400 dark:hover:bg-gray-800/50',
|
||||
}
|
||||
return map[status] || 'border-gray-200 bg-gray-50 text-gray-500'
|
||||
}
|
||||
|
||||
const purchaseStatusLabels = {
|
||||
pending: '待处理',
|
||||
ordered: '已下单',
|
||||
@@ -442,12 +458,28 @@ onUnmounted(() => {
|
||||
:to="`/warehouse/item/${item.ID}`"
|
||||
class="inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors"
|
||||
:class="item.ContainerID
|
||||
? 'border-green-200 bg-green-50 text-green-700 hover:bg-green-100 dark:border-green-800 dark:bg-green-900/30 dark:text-green-300 dark:hover:bg-green-900/50'
|
||||
? 'border-blue-200 bg-blue-50 text-blue-700 hover:bg-blue-100 dark:border-blue-800 dark:bg-blue-900/30 dark:text-blue-300 dark:hover:bg-blue-900/50'
|
||||
: 'border-gray-200 bg-gray-50 text-gray-500 hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800/30 dark:text-gray-400 dark:hover:bg-gray-800/50'"
|
||||
>
|
||||
<IconPackage :size="12" />
|
||||
{{ item.Name }}
|
||||
<span v-if="item.SerialNumber" :class="item.ContainerID ? 'text-green-500' : 'text-gray-400'">-{{ item.SerialNumber }}</span>
|
||||
<span v-if="item.SerialNumber" :class="item.ContainerID ? 'text-blue-500' : 'text-gray-400'">-{{ item.SerialNumber }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 关联客户 -->
|
||||
<div v-if="linkedCustomers.length > 0">
|
||||
<label class="mb-1 block text-xs font-medium text-gray-400">{{ t('work_order.linked_customers') }}</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<RouterLink
|
||||
v-for="c in linkedCustomers"
|
||||
:key="c.id"
|
||||
:to="`/customer/detail/${c.id}`"
|
||||
class="inline-flex items-center gap-1 rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-100 dark:border-blue-800 dark:bg-blue-900/30 dark:text-blue-300 dark:hover:bg-blue-900/50"
|
||||
>
|
||||
<IconUser :size="12" />
|
||||
{{ (c.last_name || '') + (c.first_name ? ' ' + c.first_name : '') }}
|
||||
<span v-if="c.primary_phone" class="text-blue-500">{{ c.primary_phone }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
@@ -459,15 +491,11 @@ onUnmounted(() => {
|
||||
v-for="po in allPurchaseOrders"
|
||||
:key="po.id"
|
||||
:to="`/purchase/showorder/${po.id}`"
|
||||
class="inline-flex items-center gap-1 rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-100 dark:border-blue-800 dark:bg-blue-900/30 dark:text-blue-300 dark:hover:bg-blue-900/50"
|
||||
class="inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors"
|
||||
:class="getPoBubbleClass(po.status)"
|
||||
>
|
||||
#{{ po.id }} {{ po.title || '' }}
|
||||
<span
|
||||
class="ml-1 rounded px-1.5 py-0.5 text-[10px]"
|
||||
:class="getPurchaseStatusClass(po.status)"
|
||||
>
|
||||
{{ getPurchaseStatusLabel(po.status) }}
|
||||
</span>
|
||||
<span class="opacity-80">{{ getPurchaseStatusLabel(po.status) }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
@@ -528,11 +556,12 @@ onUnmounted(() => {
|
||||
<div
|
||||
v-for="po in selectedPurchaseOrders"
|
||||
:key="po.id"
|
||||
class="inline-flex items-center gap-1 rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700 dark:border-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
|
||||
class="inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors"
|
||||
:class="getPoBubbleClass(po.status)"
|
||||
>
|
||||
#{{ po.id }} {{ po.title || '' }}
|
||||
<button
|
||||
class="ml-0.5 rounded-full p-0.5 transition-colors hover:bg-blue-200 dark:hover:bg-blue-800"
|
||||
class="ml-0.5 rounded-full p-0.5 transition-colors hover:bg-black/10 dark:hover:bg-white/10"
|
||||
@click="removePurchaseOrder(po.id)"
|
||||
>
|
||||
<IconX :size="12" />
|
||||
@@ -705,15 +734,11 @@ onUnmounted(() => {
|
||||
v-for="po in commit.purchaseOrders"
|
||||
:key="po.id"
|
||||
:to="`/purchase/showorder/${po.id}`"
|
||||
class="inline-flex items-center gap-1 rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-100 dark:border-blue-800 dark:bg-blue-900/30 dark:text-blue-300 dark:hover:bg-blue-900/50"
|
||||
class="inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors"
|
||||
:class="getPoBubbleClass(po.status)"
|
||||
>
|
||||
#{{ po.id }} {{ po.title || '' }}
|
||||
<span
|
||||
class="rounded-full px-1.5 py-0.5 text-[10px]"
|
||||
:class="getPurchaseStatusClass(po.status)"
|
||||
>
|
||||
{{ getPurchaseStatusLabel(po.status) }}
|
||||
</span>
|
||||
<span class="opacity-80">{{ getPurchaseStatusLabel(po.status) }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
Reference in New Issue
Block a user