From af5dc954e3c7ecc624a0c8bdd3c7be64b73c67e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=96=87=E5=B3=B0?= Date: Wed, 29 Apr 2026 14:52:42 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B7=A5=E5=8D=95=E5=8F=AF=E4=BB=A5=E5=85=B3?= =?UTF-8?q?=E8=81=94=E5=AE=A2=E6=88=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .workbuddy/memory/2026-04-29.md | 36 ++ backend/my_work/routers/apiCustomer.go | 502 +++++++++++++++++- backend/my_work/routers/apiSysAdmin.go | 4 + backend/my_work/routers/apiWorkOrder.go | 225 +++++++- backend/my_work/routers/binds.go | 10 + frontend/ops_vue_js/src/api/customer.js | 28 + frontend/ops_vue_js/src/api/work_order.js | 10 + frontend/ops_vue_js/src/i18n/en.json | 60 +++ frontend/ops_vue_js/src/i18n/zh-CN.json | 60 +++ frontend/ops_vue_js/src/router/index.js | 25 +- .../src/views/customer/CustomerDetail.vue | 239 +++++++++ .../src/views/customer/CustomerEdit.vue | 42 ++ .../src/views/customer/CustomerFormModal.vue | 267 ++++++++++ .../src/views/customer/CustomerList.vue | 276 ++++++++++ .../src/views/sysadmin/SysAdminView.vue | 7 +- .../src/views/work_order/AddEditWorkOrder.vue | 224 ++++++-- .../src/views/work_order/ShowWorkOrder.vue | 61 ++- 17 files changed, 1993 insertions(+), 83 deletions(-) create mode 100644 frontend/ops_vue_js/src/api/customer.js create mode 100644 frontend/ops_vue_js/src/views/customer/CustomerDetail.vue create mode 100644 frontend/ops_vue_js/src/views/customer/CustomerEdit.vue create mode 100644 frontend/ops_vue_js/src/views/customer/CustomerFormModal.vue create mode 100644 frontend/ops_vue_js/src/views/customer/CustomerList.vue diff --git a/.workbuddy/memory/2026-04-29.md b/.workbuddy/memory/2026-04-29.md index 656dfa5..8a110f5 100644 --- a/.workbuddy/memory/2026-04-29.md +++ b/.workbuddy/memory/2026-04-29.md @@ -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` 字段,重建物品/客户关联绑定 diff --git a/backend/my_work/routers/apiCustomer.go b/backend/my_work/routers/apiCustomer.go index c7f3df6..e5c3663 100644 --- a/backend/my_work/routers/apiCustomer.go +++ b/backend/my_work/routers/apiCustomer.go @@ -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), + }) + }) } diff --git a/backend/my_work/routers/apiSysAdmin.go b/backend/my_work/routers/apiSysAdmin.go index af25214..13ec8e0 100644 --- a/backend/my_work/routers/apiSysAdmin.go +++ b/backend/my_work/routers/apiSysAdmin.go @@ -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) diff --git a/backend/my_work/routers/apiWorkOrder.go b/backend/my_work/routers/apiWorkOrder.go index 6eec00f..b8be3bb 100644 --- a/backend/my_work/routers/apiWorkOrder.go +++ b/backend/my_work/routers/apiWorkOrder.go @@ -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) diff --git a/backend/my_work/routers/binds.go b/backend/my_work/routers/binds.go index b0b588d..23752c8 100644 --- a/backend/my_work/routers/binds.go +++ b/backend/my_work/routers/binds.go @@ -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{}, ) } diff --git a/frontend/ops_vue_js/src/api/customer.js b/frontend/ops_vue_js/src/api/customer.js new file mode 100644 index 0000000..416fbb2 --- /dev/null +++ b/frontend/ops_vue_js/src/api/customer.js @@ -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) + }, +} diff --git a/frontend/ops_vue_js/src/api/work_order.js b/frontend/ops_vue_js/src/api/work_order.js index a65d20b..0b24008 100644 --- a/frontend/ops_vue_js/src/api/work_order.js +++ b/frontend/ops_vue_js/src/api/work_order.js @@ -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 }) + }, } diff --git a/frontend/ops_vue_js/src/i18n/en.json b/frontend/ops_vue_js/src/i18n/en.json index 3d8de5e..50cde54 100644 --- a/frontend/ops_vue_js/src/i18n/en.json +++ b/frontend/ops_vue_js/src/i18n/en.json @@ -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" } } diff --git a/frontend/ops_vue_js/src/i18n/zh-CN.json b/frontend/ops_vue_js/src/i18n/zh-CN.json index 70428e7..2ffd577 100644 --- a/frontend/ops_vue_js/src/i18n/zh-CN.json +++ b/frontend/ops_vue_js/src/i18n/zh-CN.json @@ -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": "客户不存在" } } diff --git a/frontend/ops_vue_js/src/router/index.js b/frontend/ops_vue_js/src/router/index.js index 77ca1d6..cee3ed8 100644 --- a/frontend/ops_vue_js/src/router/index.js +++ b/frontend/ops_vue_js/src/router/index.js @@ -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'), + }, ], }, diff --git a/frontend/ops_vue_js/src/views/customer/CustomerDetail.vue b/frontend/ops_vue_js/src/views/customer/CustomerDetail.vue new file mode 100644 index 0000000..b849ce0 --- /dev/null +++ b/frontend/ops_vue_js/src/views/customer/CustomerDetail.vue @@ -0,0 +1,239 @@ + + + diff --git a/frontend/ops_vue_js/src/views/customer/CustomerEdit.vue b/frontend/ops_vue_js/src/views/customer/CustomerEdit.vue new file mode 100644 index 0000000..5d5c2a4 --- /dev/null +++ b/frontend/ops_vue_js/src/views/customer/CustomerEdit.vue @@ -0,0 +1,42 @@ + + + diff --git a/frontend/ops_vue_js/src/views/customer/CustomerFormModal.vue b/frontend/ops_vue_js/src/views/customer/CustomerFormModal.vue new file mode 100644 index 0000000..2c07338 --- /dev/null +++ b/frontend/ops_vue_js/src/views/customer/CustomerFormModal.vue @@ -0,0 +1,267 @@ + + + diff --git a/frontend/ops_vue_js/src/views/customer/CustomerList.vue b/frontend/ops_vue_js/src/views/customer/CustomerList.vue new file mode 100644 index 0000000..d128d69 --- /dev/null +++ b/frontend/ops_vue_js/src/views/customer/CustomerList.vue @@ -0,0 +1,276 @@ + + + diff --git a/frontend/ops_vue_js/src/views/sysadmin/SysAdminView.vue b/frontend/ops_vue_js/src/views/sysadmin/SysAdminView.vue index 72e14af..e77a9e7 100644 --- a/frontend/ops_vue_js/src/views/sysadmin/SysAdminView.vue +++ b/frontend/ops_vue_js/src/views/sysadmin/SysAdminView.vue @@ -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(() => { + + {{ item.Name }}{{ item.SerialNumber ? ' - ' + item.SerialNumber : '' }} + + -
+
+ +
+ + + +
+
+ + {{ (customer.last_name || '') + (customer.first_name ? ' ' + customer.first_name : '') }} + +
+
+ + +
+ + +
+
+
+ {{ (customer.last_name || '') + (customer.first_name ? ' ' + customer.first_name : '') }} +
+
+ {{ customer.primary_phone }}{{ customer.primary_phone && customer.primary_company ? ' · ' : '' }}{{ customer.primary_company }} +
+
+
+ +
+ {{ t('message.loading') }} +
+ +
+ {{ t('work_order.linked_customer_not_found') }} +
+
+
+
+
+ +
+ +
+ + + {{ (c.last_name || '') + (c.first_name ? ' ' + c.first_name : '') }} + {{ c.primary_phone }}
@@ -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 || '' }} - - {{ getPurchaseStatusLabel(po.status) }} - + {{ getPurchaseStatusLabel(po.status) }} @@ -528,11 +556,12 @@ onUnmounted(() => {
#{{ po.id }} {{ po.title || '' }}