diff --git a/.workbuddy/memory/2026-04-23.md b/.workbuddy/memory/2026-04-23.md new file mode 100644 index 0000000..1b4cae6 --- /dev/null +++ b/.workbuddy/memory/2026-04-23.md @@ -0,0 +1,49 @@ +# 2026-04-23 工作记录 + +## 分析 OPS2 流程管理平台架构 + +完成了对项目前后端完整运行逻辑的分析: + +- **项目性质**:内部流程管理平台,前后端同源部署(后端 Gin serve 前端 dist) +- **后端**:Go 1.24 + Gin + GORM,支持 SQLite/MySQL/PostgreSQL 三选一 +- **前端**:Vue 3 + Vite + Pinia + Tailwind CSS,Hash History 路由 +- **认证**:自实现 Cookie Token(非 JWT),存于数据库,支持"记住我"滚动续期 +- **数据库**:13 张表,涵盖用户体系(4张)、认证日志(2张)、文件(1张)、日程(2张)、采购(5张,含软删除+操作日志双保险) +- **核心功能**:日程日历(FullCalendar,公开可访问)+ 采购流程管理(完整状态流转)+ 用户系统 +- **权限设计**:基于用户组(schedule_admin / purchase_admin),内存缓存管理员 ID 列表 +- **文件系统**:SHA256 内容寻址,自动去重存储 +- **API 协议**:统一 `{ userCookieValue, data }` 请求体,统一 `{ err_code, err_msg, return }` 响应体 + +## 新增 work_order 工单模块 + +完整实现了工单模块,文件清单: + +**后端(backend/my_work/routers/)** +- `apiWorkOrder.go`:4张表(TabWorkOrder / TabWorkOrderFileBind / TabWorkOrderCommit / TabWorkOrderLog)+ 7个接口(add/update/list/get/commit/delete/count)+ work_order_admin 用户组 +- `api.go`:注册 `/api/work_order/*` 路由 +- `main.go`:注册 `ApiWorkOrderInit()` + +**前端(frontend/ops_vue_js/src/)** +- `api/work_order.js`:API 封装 +- `views/work_order/WorkOrderList.vue`:工单列表页(分页+状态筛选) +- `views/work_order/AddEditWorkOrder.vue`:新增/编辑页(图片上传 useDropzone) +- `views/work_order/ShowWorkOrder.vue`:详情页(进度时间线+新增进度表单) +- `router/index.js`:注册4条路由(list/add/edit/:id/show/:id) +- `components/AppHeader.vue`:导航菜单添加"工单"入口 +- `i18n/zh-CN.json` & `i18n/en.json`:添加 work_order 翻译节和 appname.work_order + +**工单状态枚举**:pending/checked/parts_ordered/repaired/returned/unrepairable + +**commit 图片支持**: +- 后端:新增 `TabWorkOrderCommitFileBind` 表(commit_id/work_order_id/file_id),commit 接口接收 `photos` 数组,get 接口返回每条 commit 的 photos +- 前端:ShowWorkOrder.vue 新增进度表单底部加了 useDropzone 组件,每条进度时间线下方展示关联图片 +- API:`commit(id, status, comment, photos)` 函数签名新增 photos 参数 + +**工单关联采购订单**: +- 后端:新增 `TabWorkOrderPurchaseOrderBind` 表(work_order_id/commit_id/purchase_order_id),支持多对多关联 +- commit 接口新增 `purchaseOrderIds` 参数([]uint) +- get 接口每条 commit 返回 `purchaseOrders` 数组(包含 id/title/status) +- 新增 `/search_purchase_orders` 接口:空搜索返回最新5条,支持 ID 精确匹配和标题/备注模糊匹配 +- 前端:状态选 `parts_ordered` 时显示采购订单搜索框(防抖300ms),输入框获取焦点自动搜索 +- 时间线每条 commit 下方展示关联的采购订单链接(点击跳转到采购详情页) + diff --git a/backend/my_work/main.go b/backend/my_work/main.go index 437f86e..d193160 100644 --- a/backend/my_work/main.go +++ b/backend/my_work/main.go @@ -70,6 +70,7 @@ func main() { routers.ApiFilesInit() routers.ApiScheduleInit() routers.ApiPurchaseInit() + routers.ApiWorkOrderInit() //创建必要目录 for _, path := range models.ConfigsFile.Pahts { diff --git a/backend/my_work/routers/api.go b/backend/my_work/routers/api.go index 44f86c0..cc4f8f1 100644 --- a/backend/my_work/routers/api.go +++ b/backend/my_work/routers/api.go @@ -55,6 +55,7 @@ func ApiRoot(r *gin.RouterGroup) { ApiFiles(r.Group("/files")) ApiPurchase(r.Group("/purchase")) ApiSchedule(r.Group("/schedule")) + ApiWorkOrder(r.Group("/work_order")) r.GET("/", func(ctx *gin.Context) { ReturnJson(ctx, "apiOK", gin.H{ "isOpsApiRoot":true, diff --git a/backend/my_work/routers/apiPurchase.go b/backend/my_work/routers/apiPurchase.go index b7ba704..f0929f7 100644 --- a/backend/my_work/routers/apiPurchase.go +++ b/backend/my_work/routers/apiPurchase.go @@ -3,6 +3,7 @@ package routers import ( "encoding/json" "ops/models" + parsefmt "fmt" "slices" "strings" "time" @@ -294,10 +295,6 @@ func ApiPurchase(r *gin.RouterGroup) { } oldStatus := order.OrderStatus - if oldStatus == from.Status { - ReturnJson(ctx, "status_no_change", nil) - return - } // 更新状态 updates := map[string]interface{}{ @@ -359,38 +356,46 @@ func ApiPurchase(r *gin.RouterGroup) { //fmt.Println(user) // DebugPrintJson(data) - type From_purchase_getorders struct { - Search string - Status string - Entries int - Page int - } - - var jsondata From_purchase_getorders - if err := decodeJSON(data, &jsondata); err == nil { - //fmt.Println(jsondata) - - is_data_ok := true - - if jsondata.Entries <= 0 || jsondata.Entries > 300 { - is_data_ok = false - } - if jsondata.Page <= 0 { - is_data_ok = false + type From_purchase_getorders struct { + Search string + Status string + Entries int + Page int } - if is_data_ok { + var jsondata From_purchase_getorders + if err := decodeJSON(data, &jsondata); err == nil { + //fmt.Println(jsondata) - //读取有多少条目 - var count int64 - query := models.DB.Model(TabPurchaseOrder{}) - if jsondata.Search != "" { - query = query.Where("title LIKE ?", "%"+jsondata.Search+"%") + is_data_ok := true + + if jsondata.Entries <= 0 || jsondata.Entries > 300 { + is_data_ok = false } - if jsondata.Status != "" { - query = query.Where("order_status = ?", jsondata.Status) + if jsondata.Page <= 0 { + is_data_ok = false } - query.Count(&count) + + if is_data_ok { + + //读取有多少条目 + var count int64 + query := models.DB.Model(TabPurchaseOrder{}) + if jsondata.Search != "" { + // 精确匹配订单 ID + var id uint + if _, err := parsefmt.Sscanf(jsondata.Search, "%d", &id); err == nil && id > 0 { + query = query.Where("id = ?", id) + } else { + // 模糊匹配标题和用途(Remark) + query = query.Where("title LIKE ? OR remark LIKE ?", + "%"+jsondata.Search+"%", "%"+jsondata.Search+"%") + } + } + if jsondata.Status != "" { + query = query.Where("order_status = ?", jsondata.Status) + } + query.Count(&count) //读取条目 var getorders []TabPurchaseOrder diff --git a/backend/my_work/routers/apiWorkOrder.go b/backend/my_work/routers/apiWorkOrder.go new file mode 100644 index 0000000..8b9d076 --- /dev/null +++ b/backend/my_work/routers/apiWorkOrder.go @@ -0,0 +1,662 @@ +package routers + +import ( + "encoding/json" + "ops/models" + parsefmt "fmt" + "slices" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +var ( + workOrderUserGroup models.TabUserGroups_ + workOrderAdmins []uint +) + +// updateWorkOrderAdminsCash 刷新工单管理员缓存 +func updateWorkOrderAdminsCash() { + workOrderAdmins = nil + workOrderAdmins = append(workOrderAdmins, 1) // id=1 超级管理员 + var binds []models.TabUserGroupBinds_ + models.DB.Where("group_id = ?", workOrderUserGroup.ID).Find(&binds) + for _, item := range binds { + if !slices.Contains(workOrderAdmins, item.UserID) { + workOrderAdmins = append(workOrderAdmins, item.UserID) + } + } +} + +// canModifyWorkOrder 判断是否有权限修改/删除工单(创建者或管理员) +func canModifyWorkOrder(userID, creatorUserID uint) bool { + if slices.Contains(workOrderAdmins, userID) { + return true + } + return userID == creatorUserID +} + +// ---------- 数据表结构 ---------- + +type TabWorkOrder struct { + ID uint `gorm:"primarykey"` + UserID uint `gorm:"not null;comment:创建人ID"` + Title string `gorm:"size:200;not null;comment:工单标题"` + Description string `gorm:"type:text;comment:问题描述"` + CurrentStatus string `gorm:"size:50;default:pending;comment:当前状态: pending-待处理 checked-已检查 parts_ordered-已下单零件 repaired-已维修 returned-已送还 unrepairable-无法维修"` + CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"` + UpdatedAt *time.Time `gorm:"type:datetime;autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index"` +} + +type TabWorkOrderFileBind struct { + ID uint `gorm:"primarykey"` + WorkOrderID uint `gorm:"not null;index;comment:关联工单ID"` + FileID uint `gorm:"not null;comment:关联文件ID"` + CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"` +} + +type TabWorkOrderCommit struct { + ID uint `gorm:"primarykey"` + WorkOrderID uint `gorm:"not null;index;comment:关联工单ID"` + UserID uint `gorm:"not null;comment:操作人ID"` + Action string `gorm:"size:50;not null;comment:操作类型: create-创建 create_status-状态变更"` + Status string `gorm:"size:50;comment:变更后的状态"` + OldStatus string `gorm:"size:50;comment:变更前的状态"` + Comment string `gorm:"type:text;comment:备注"` + IP string `gorm:"size:50;comment:操作IP"` + CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"` +} + +type TabWorkOrderLog struct { + ID uint `gorm:"primarykey"` + WorkOrderID 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"` +} + +type TabWorkOrderCommitFileBind struct { + ID uint `gorm:"primarykey"` + CommitID uint `gorm:"not null;index;comment:关联进度ID"` + FileID uint `gorm:"not null;comment:关联文件ID"` + WorkOrderID uint `gorm:"not null;index;comment:关联工单ID"` + CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"` +} + +// TabWorkOrderPurchaseOrderBind 工单与采购订单的关联表 +type TabWorkOrderPurchaseOrderBind struct { + ID uint `gorm:"primarykey"` + WorkOrderID uint `gorm:"not null;index;comment:关联工单ID"` + CommitID uint `gorm:"not null;index;comment:关联进度ID"` + PurchaseOrderID uint `gorm:"not null;comment:关联采购订单ID"` + CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"` +} + +// PurchaseOrderInfo 采购订单简要信息 +type PurchaseOrderInfo struct { + ID uint `json:"id"` + Title string `json:"title"` + Status string `json:"status"` +} + +// ---------- 初始化 ---------- + +func ApiWorkOrderInit() { + models.DB.AutoMigrate(&TabWorkOrder{}) + models.DB.AutoMigrate(&TabWorkOrderFileBind{}) + models.DB.AutoMigrate(&TabWorkOrderCommit{}) + models.DB.AutoMigrate(&TabWorkOrderLog{}) + models.DB.AutoMigrate(&TabWorkOrderCommitFileBind{}) + models.DB.AutoMigrate(&TabWorkOrderPurchaseOrderBind{}) + + workOrderUserGroup.Name = "work_order_admin" + if models.DB.Where(&workOrderUserGroup).First(&workOrderUserGroup).Error == nil { + updateWorkOrderAdminsCash() + } else { + workOrderUserGroup.Type = "usergroup" + models.DB.Create(&workOrderUserGroup) + } +} + +// ---------- 路由注册 ---------- + +func ApiWorkOrder(r *gin.RouterGroup) { + + // 新增工单 + r.POST("/add", func(ctx *gin.Context) { + isAuth, user, data := AuthenticationAuthority(ctx) + if !isAuth { + ReturnJson(ctx, "userCookieError", nil) + return + } + + type FromAdd struct { + Title string `json:"title"` + Description string `json:"description"` + Photos []string `json:"photos"` + } + var from FromAdd + if err := decodeJSON(data, &from); err != nil || from.Title == "" { + ReturnJson(ctx, "jsonErr", nil) + return + } + + // 校验图片哈希 + for _, hash := range from.Photos { + if models.IsContainsSpecialChar(hash) { + ReturnJson(ctx, "photo_hash_invalid", nil) + return + } + } + + order := TabWorkOrder{ + UserID: user.ID, + Title: from.Title, + Description: from.Description, + CurrentStatus: "pending", + } + models.DB.Create(&order) + + // 绑定图片 + for _, hash := range from.Photos { + findFile := TabFileInfo_{Sha256: hash, Type: "image"} + if models.DB.Where(&findFile).First(&findFile).Error == nil { + models.DB.Create(&TabWorkOrderFileBind{ + WorkOrderID: order.ID, + FileID: findFile.ID, + }) + } + } + + // 写创建 commit + models.DB.Create(&TabWorkOrderCommit{ + WorkOrderID: order.ID, + UserID: user.ID, + Action: "create", + Status: "pending", + OldStatus: "", + Comment: "工单创建", + IP: ctx.ClientIP(), + }) + + // 写操作日志 + newContent, _ := json.Marshal(from) + models.DB.Create(&TabWorkOrderLog{ + WorkOrderID: order.ID, + UserID: user.ID, + ActionType: "create", + NewContent: string(newContent), + IP: ctx.ClientIP(), + }) + + ReturnJson(ctx, "apiOK", gin.H{"id": order.ID}) + }) + + // 编辑工单 + r.POST("/update", func(ctx *gin.Context) { + isAuth, user, data := AuthenticationAuthority(ctx) + if !isAuth { + ReturnJson(ctx, "userCookieError", nil) + 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 + } + + // 校验图片哈希 + for _, hash := range from.Photos { + if models.IsContainsSpecialChar(hash) { + ReturnJson(ctx, "photo_hash_invalid", nil) + return + } + } + + var order TabWorkOrder + if err := models.DB.Where("id = ?", from.ID).First(&order).Error; err != nil { + ReturnJson(ctx, "order_not_found", nil) + return + } + + if !canModifyWorkOrder(user.ID, order.UserID) { + ReturnJson(ctx, "no_permission", nil) + return + } + + oldContent, _ := json.Marshal(order) + + models.DB.Model(&order).Updates(map[string]interface{}{ + "title": from.Title, + "description": from.Description, + }) + + // 重建图片绑定 + models.DB.Where("work_order_id = ?", from.ID).Delete(&TabWorkOrderFileBind{}) + for _, hash := range from.Photos { + findFile := TabFileInfo_{Sha256: hash, Type: "image"} + if models.DB.Where(&findFile).First(&findFile).Error == nil { + models.DB.Create(&TabWorkOrderFileBind{ + WorkOrderID: from.ID, + FileID: findFile.ID, + }) + } + } + + newContent, _ := json.Marshal(from) + models.DB.Create(&TabWorkOrderLog{ + WorkOrderID: from.ID, + UserID: user.ID, + ActionType: "update", + OldContent: string(oldContent), + NewContent: string(newContent), + IP: ctx.ClientIP(), + }) + + ReturnJson(ctx, "apiOK", nil) + }) + + // 获取工单列表 + r.POST("/list", func(ctx *gin.Context) { + isAuth, _, data := AuthenticationAuthority(ctx) + if !isAuth { + ReturnJson(ctx, "userCookieError", nil) + return + } + + type FromList struct { + Search string `json:"search"` + Status string `json:"status"` + Entries int `json:"entries"` + Page int `json:"page"` + } + var from FromList + if err := decodeJSON(data, &from); err != nil { + ReturnJson(ctx, "jsonErr", nil) + return + } + if from.Entries <= 0 || from.Entries > 300 { + from.Entries = 10 + } + if from.Page <= 0 { + from.Page = 1 + } + + var count int64 + query := models.DB.Model(&TabWorkOrder{}) + if from.Search != "" { + query = query.Where("title LIKE ?", "%"+from.Search+"%") + } + if from.Status != "" { + query = query.Where("current_status = ?", from.Status) + } + query.Count(&count) + + var orders []TabWorkOrder + query.Order("created_at DESC"). + Offset(from.Entries * (from.Page - 1)). + Limit(from.Entries). + Find(&orders) + + ReturnJson(ctx, "apiOK", gin.H{ + "all_count": count, + "all_orders": orders, + }) + }) + + // 获取工单详情 + r.POST("/get", func(ctx *gin.Context) { + isAuth, user, data := AuthenticationAuthority(ctx) + if !isAuth { + ReturnJson(ctx, "userCookieError", nil) + return + } + + type FromGet struct { + ID uint `json:"id"` + } + var from FromGet + if err := decodeJSON(data, &from); err != nil || from.ID == 0 { + ReturnJson(ctx, "jsonErr", nil) + return + } + + var order TabWorkOrder + if err := models.DB.Where("id = ?", from.ID).First(&order).Error; err != nil { + ReturnJson(ctx, "order_not_found", nil) + return + } + + // 关联图片 + var binds []TabWorkOrderFileBind + models.DB.Where("work_order_id = ?", from.ID).Find(&binds) + var fileIDs []uint + for _, b := range binds { + fileIDs = append(fileIDs, b.FileID) + } + var files []TabFileInfo_ + if len(fileIDs) > 0 { + models.DB.Where("id IN ?", fileIDs).Find(&files) + } + + // commits + var commits []TabWorkOrderCommit + models.DB.Where("work_order_id = ?", from.ID).Order("created_at ASC").Find(&commits) + + // 为每条 commit 附加图片和采购订单 + type CommitWithPhotos struct { + TabWorkOrderCommit + Photos []TabFileInfo_ `json:"photos"` + PurchaseOrders []PurchaseOrderInfo `json:"purchaseOrders"` + } + var commitsWithPhotos []CommitWithPhotos + for _, c := range commits { + item := CommitWithPhotos{TabWorkOrderCommit: c, Photos: []TabFileInfo_{}, PurchaseOrders: []PurchaseOrderInfo{}} + + // 附加图片 + var fileBinds []TabWorkOrderCommitFileBind + models.DB.Where("commit_id = ?", c.ID).Find(&fileBinds) + if len(fileBinds) > 0 { + var fileIDs []uint + for _, fb := range fileBinds { + fileIDs = append(fileIDs, fb.FileID) + } + models.DB.Where("id IN ?", fileIDs).Find(&item.Photos) + } + + // 附加采购订单 + var poBinds []TabWorkOrderPurchaseOrderBind + models.DB.Where("commit_id = ?", c.ID).Find(&poBinds) + if len(poBinds) > 0 { + var poIDs []uint + for _, pb := range poBinds { + poIDs = append(poIDs, pb.PurchaseOrderID) + } + var pos []TabPurchaseOrder + models.DB.Where("id IN ?", poIDs).Find(&pos) + for _, po := range pos { + item.PurchaseOrders = append(item.PurchaseOrders, PurchaseOrderInfo{ + ID: po.ID, + Title: po.Title, + Status: po.OrderStatus, + }) + } + } + + commitsWithPhotos = append(commitsWithPhotos, item) + } + + canModify := canModifyWorkOrder(user.ID, order.UserID) + + ReturnJson(ctx, "apiOK", gin.H{ + "order": order, + "canModify": canModify, + "photos": files, + "commits": commitsWithPhotos, + }) + }) + + // 新增进度 commit(状态推进) + r.POST("/commit", func(ctx *gin.Context) { + isAuth, user, data := AuthenticationAuthority(ctx) + if !isAuth { + ReturnJson(ctx, "userCookieError", nil) + return + } + + type FromCommit struct { + ID uint `json:"id"` + Status string `json:"status"` + Comment string `json:"comment"` + Photos []string `json:"photos"` + PurchaseOrderIDs []uint `json:"purchaseOrderIds"` // 关联的采购订单ID列表 + } + var from FromCommit + if err := decodeJSON(data, &from); err != nil || from.ID == 0 { + ReturnJson(ctx, "jsonErr", nil) + return + } + + validStatuses := map[string]bool{ + "pending": true, + "checked": true, + "parts_ordered": true, + "repaired": true, + "returned": true, + "unrepairable": true, + } + if !validStatuses[from.Status] { + ReturnJson(ctx, "invalid_status", nil) + return + } + + // 校验图片哈希 + for _, hash := range from.Photos { + if models.IsContainsSpecialChar(hash) { + ReturnJson(ctx, "photo_hash_invalid", nil) + return + } + } + + var order TabWorkOrder + if err := models.DB.Where("id = ?", from.ID).First(&order).Error; err != nil { + ReturnJson(ctx, "order_not_found", nil) + return + } + + if !canModifyWorkOrder(user.ID, order.UserID) { + ReturnJson(ctx, "no_permission", nil) + return + } + + oldStatus := order.CurrentStatus + models.DB.Model(&order).Update("current_status", from.Status) + + comment := from.Comment + if comment == "" { + comment = "状态变更为: " + from.Status + } + + commit := TabWorkOrderCommit{ + WorkOrderID: order.ID, + UserID: user.ID, + Action: "create_status", + Status: from.Status, + OldStatus: oldStatus, + Comment: comment, + IP: ctx.ClientIP(), + } + models.DB.Create(&commit) + + // 绑定进度图片 + for _, hash := range from.Photos { + findFile := TabFileInfo_{Sha256: hash, Type: "image"} + if models.DB.Where(&findFile).First(&findFile).Error == nil { + models.DB.Create(&TabWorkOrderCommitFileBind{ + CommitID: commit.ID, + WorkOrderID: order.ID, + FileID: findFile.ID, + }) + } + } + + // 绑定采购订单(去重) + if len(from.PurchaseOrderIDs) > 0 { + seen := make(map[uint]bool) + for _, pid := range from.PurchaseOrderIDs { + if !seen[pid] { + seen[pid] = true + models.DB.Create(&TabWorkOrderPurchaseOrderBind{ + WorkOrderID: order.ID, + CommitID: commit.ID, + PurchaseOrderID: pid, + }) + } + } + } + + newContent, _ := json.Marshal(map[string]string{ + "status": from.Status, + "comment": comment, + }) + oldContent, _ := json.Marshal(map[string]string{ + "status": oldStatus, + }) + models.DB.Create(&TabWorkOrderLog{ + WorkOrderID: from.ID, + UserID: user.ID, + ActionType: "update_status", + OldContent: string(oldContent), + NewContent: string(newContent), + IP: ctx.ClientIP(), + Remark: comment, + }) + + ReturnJson(ctx, "apiOK", nil) + }) + + // 删除工单 + r.POST("/delete", func(ctx *gin.Context) { + isAuth, user, data := AuthenticationAuthority(ctx) + if !isAuth { + ReturnJson(ctx, "userCookieError", nil) + return + } + + type FromDelete struct { + ID uint `json:"id"` + } + var from FromDelete + if err := decodeJSON(data, &from); err != nil || from.ID == 0 { + ReturnJson(ctx, "jsonErr", nil) + return + } + + var order TabWorkOrder + if err := models.DB.Where("id = ?", from.ID).First(&order).Error; err != nil { + ReturnJson(ctx, "order_not_found", nil) + return + } + + if !canModifyWorkOrder(user.ID, order.UserID) { + ReturnJson(ctx, "no_permission", nil) + return + } + + models.DB.Where("work_order_id = ?", from.ID).Delete(&TabWorkOrderFileBind{}) + models.DB.Where("work_order_id = ?", from.ID).Delete(&TabWorkOrderCommitFileBind{}) + models.DB.Where("work_order_id = ?", from.ID).Delete(&TabWorkOrderPurchaseOrderBind{}) + models.DB.Where("work_order_id = ?", from.ID).Delete(&TabWorkOrderCommit{}) + models.DB.Where("work_order_id = ?", from.ID).Delete(&TabWorkOrderLog{}) + models.DB.Delete(&order) + + ReturnJson(ctx, "apiOK", nil) + }) + + // 获取工单数量统计 + r.POST("/count", func(ctx *gin.Context) { + isAuth, _, _ := AuthenticationAuthority(ctx) + if !isAuth { + ReturnJson(ctx, "userCookieError", nil) + return + } + + type WOCount struct { + Pending int64 `json:"pending"` + Checked int64 `json:"checked"` + PartsOrdered int64 `json:"parts_ordered"` + Repaired int64 `json:"repaired"` + Returned int64 `json:"returned"` + Unrepairable int64 `json:"unrepairable"` + Total int64 `json:"total"` + } + var count WOCount + models.DB.Model(&TabWorkOrder{}).Count(&count.Total) + models.DB.Model(&TabWorkOrder{}).Where("current_status = ?", "pending").Count(&count.Pending) + models.DB.Model(&TabWorkOrder{}).Where("current_status = ?", "checked").Count(&count.Checked) + models.DB.Model(&TabWorkOrder{}).Where("current_status = ?", "parts_ordered").Count(&count.PartsOrdered) + models.DB.Model(&TabWorkOrder{}).Where("current_status = ?", "repaired").Count(&count.Repaired) + models.DB.Model(&TabWorkOrder{}).Where("current_status = ?", "returned").Count(&count.Returned) + models.DB.Model(&TabWorkOrder{}).Where("current_status = ?", "unrepairable").Count(&count.Unrepairable) + + ReturnJson(ctx, "apiOK", gin.H{ + "pending": count.Pending, + "checked": count.Checked, + "parts_ordered": count.PartsOrdered, + "repaired": count.Repaired, + "returned": count.Returned, + "unrepairable": count.Unrepairable, + "total": count.Total, + }) + }) + + // 搜索采购订单(用于工单关联) + r.POST("/search_purchase_orders", func(ctx *gin.Context) { + isAuth, _, data := AuthenticationAuthority(ctx) + if !isAuth { + ReturnJson(ctx, "userCookieError", nil) + return + } + + type FromSearch struct { + Search string `json:"search"` + Limit int `json:"limit"` + } + var from FromSearch + if err := decodeJSON(data, &from); err != nil { + ReturnJson(ctx, "jsonErr", nil) + return + } + + if from.Limit <= 0 || from.Limit > 20 { + from.Limit = 5 + } + + query := models.DB.Model(&TabPurchaseOrder{}) + + // 如果搜索词为空,返回最新的 N 条 + if from.Search != "" { + // 尝试精确匹配 ID + var id uint + if _, err := parsefmt.Sscanf(from.Search, "%d", &id); err == nil && id > 0 { + query = query.Where("id = ?", id) + } else { + // 模糊匹配标题或备注 + query = query.Where("title LIKE ? OR remark LIKE ?", + "%"+from.Search+"%", "%"+from.Search+"%") + } + } + + var orders []TabPurchaseOrder + query.Order("created_at DESC").Limit(from.Limit).Find(&orders) + + type OrderInfo struct { + ID uint `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + } + var result []OrderInfo + for _, o := range orders { + result = append(result, OrderInfo{ + ID: o.ID, + Title: o.Title, + Status: o.OrderStatus, + }) + } + + ReturnJson(ctx, "apiOK", gin.H{"orders": result}) + }) +} diff --git a/frontend/ops_vue_js/src/api/work_order.js b/frontend/ops_vue_js/src/api/work_order.js new file mode 100644 index 0000000..74c41fb --- /dev/null +++ b/frontend/ops_vue_js/src/api/work_order.js @@ -0,0 +1,43 @@ +import { api } from './index' + +export const workOrderApi = { + /** 获取工单列表 */ + getList(params = {}) { + return api.post('/work_order/list', params) + }, + + /** 获取工单数量统计 */ + getCount() { + return api.post('/work_order/count', {}) + }, + + /** 新增工单 */ + add(data) { + return api.post('/work_order/add', data) + }, + + /** 编辑工单 */ + update(data) { + return api.post('/work_order/update', data) + }, + + /** 获取工单详情(含图片、commits) */ + get(id) { + return api.post('/work_order/get', { id }) + }, + + /** 新增进度(状态变更) */ + commit(id, status, comment = '', photos = [], purchaseOrderIds = []) { + return api.post('/work_order/commit', { id, status, comment, photos, purchaseOrderIds }) + }, + + /** 删除工单 */ + delete(id) { + return api.post('/work_order/delete', { id }) + }, + + /** 搜索采购订单 */ + searchPurchaseOrders(search = '', limit = 5) { + return api.post('/work_order/search_purchase_orders', { search, limit }) + }, +} diff --git a/frontend/ops_vue_js/src/components/AppHeader.vue b/frontend/ops_vue_js/src/components/AppHeader.vue index e11f014..a609931 100644 --- a/frontend/ops_vue_js/src/components/AppHeader.vue +++ b/frontend/ops_vue_js/src/components/AppHeader.vue @@ -50,6 +50,7 @@ const navItems = computed(() => [ { label: t("appname.home"), to: "/" }, { label: t("appname.schedule"), to: "/schedule" }, { label: t("appname.purchase"), to: "/purchase" }, + { label: t("appname.work_order"), to: "/work_order" }, // { label: t("appname.warehouse"), to: "/warehouse" }, ]); diff --git a/frontend/ops_vue_js/src/components/useDropzone.vue b/frontend/ops_vue_js/src/components/useDropzone.vue index 667b972..fa3bb5b 100644 --- a/frontend/ops_vue_js/src/components/useDropzone.vue +++ b/frontend/ops_vue_js/src/components/useDropzone.vue @@ -86,7 +86,7 @@ const prop = defineProps({ }, uploadURL: { type: String, - default: "/api/files/upload", + default: "/api/files/upload/image", }, /** 初始已有文件 [{ hash, name, ... }] */ initialFiles: { diff --git a/frontend/ops_vue_js/src/i18n/en.json b/frontend/ops_vue_js/src/i18n/en.json index e7e1ada..76b170b 100644 --- a/frontend/ops_vue_js/src/i18n/en.json +++ b/frontend/ops_vue_js/src/i18n/en.json @@ -22,7 +22,8 @@ "register": "Register", "schedule": "Schedule", "purchase": "Purchase", - "warehouse": "Warehouse" + "warehouse": "Warehouse", + "work_order": "Work Order" }, "tagadder": { "not_fund_item": "No matching items found", @@ -78,6 +79,7 @@ "show": "Show", "entries": "entries", "search": "Search", + "search_placeholder": "Search ID, title, or purpose", "add_part": "Add Order", "exp_report": "Export Report", "There_are_a_total_of": ",There are a total of", @@ -108,6 +110,45 @@ "edit_order": "Edit Order", "submit_changes":"Submit changes" }, + "work_order": { + "list_title": "Work Order List", + "add": "New Work Order", + "add_title": "New Work Order", + "edit_title": "Edit Work Order", + "detail_title": "Work Order Detail", + "title": "Title", + "title_placeholder": "Enter work order title", + "description": "Description", + "description_placeholder": "Enter problem description", + "photos": "Photos", + "no_photos": "No photos", + "status": "Status", + "created_at": "Created At", + "filter_all": "All", + "status_pending": "Pending", + "status_checked": "Checked", + "status_parts_ordered": "Parts Ordered", + "status_repaired": "Repaired", + "status_returned": "Returned", + "status_unrepairable": "Unrepairable", + "commit_history": "Progress History", + "no_commits": "No progress records", + "commit_create": "Work order created", + "add_commit": "Add Progress", + "commit_status_label": "Status", + "commit_comment_label": "Comment", + "commit_comment_placeholder": "Add comment (optional)", + "commit_photos_label": "Photos", + "commit_submit": "Submit", + "submit_commit": "Submit", + "edit": "Edit", + "delete": "Delete", + "back_to_list": "Back to List", + "not_found": "Work order not found", + "confirm_delete": "Are you sure you want to delete this work order? This action cannot be undone.", + "submit": "Submit", + "save_changes": "Save Changes" + }, "purchase_addorder": { "add_order": "Add Order", "edit_order": "Edit Order", diff --git a/frontend/ops_vue_js/src/i18n/zh-CN.json b/frontend/ops_vue_js/src/i18n/zh-CN.json index 356099f..7c32851 100644 --- a/frontend/ops_vue_js/src/i18n/zh-CN.json +++ b/frontend/ops_vue_js/src/i18n/zh-CN.json @@ -22,7 +22,8 @@ "register": "注册", "schedule": "日程", "purchase": "采购", - "warehouse": "仓库" + "warehouse": "仓库", + "work_order": "工单" }, "tagadder": { "not_fund_item": "没有找到匹配项", @@ -78,6 +79,7 @@ "show": "显示", "entries": "个订单", "search": "搜索", + "search_placeholder": "搜索订单号、名称、用途", "add_part": "添加订单", "exp_report": "生成报告", "There_are_a_total_of": ",一共", @@ -108,6 +110,45 @@ "edit_order": "编辑订单", "submit_changes":"提交修改" }, + "work_order": { + "list_title": "工单列表", + "add": "新增工单", + "add_title": "新增工单", + "edit_title": "编辑工单", + "detail_title": "工单详情", + "title": "工单标题", + "title_placeholder": "输入工单标题", + "description": "问题描述", + "description_placeholder": "输入问题描述", + "photos": "图片", + "no_photos": "暂无图片", + "status": "状态", + "created_at": "创建时间", + "filter_all": "全部", + "status_pending": "待处理", + "status_checked": "已检查", + "status_parts_ordered": "已下单零件", + "status_repaired": "已维修", + "status_returned": "已送还", + "status_unrepairable": "无法维修", + "commit_history": "工作进度", + "no_commits": "暂无进度记录", + "commit_create": "工单创建", + "add_commit": "新增进度", + "commit_status_label": "进度状态", + "commit_comment_label": "备注", + "commit_comment_placeholder": "输入进度备注(可选)", + "commit_photos_label": "上传图片", + "commit_submit": "提交", + "submit_commit": "提交", + "edit": "编辑", + "delete": "删除", + "back_to_list": "返回列表", + "not_found": "工单不存在", + "confirm_delete": "确定要删除此工单吗?此操作不可撤销。", + "submit": "提交", + "save_changes": "保存修改" + }, "purchase_addorder": { "add_order": "添加订单", "edit_order": "编辑订单", diff --git a/frontend/ops_vue_js/src/router/index.js b/frontend/ops_vue_js/src/router/index.js index db46aa9..576c88a 100644 --- a/frontend/ops_vue_js/src/router/index.js +++ b/frontend/ops_vue_js/src/router/index.js @@ -54,6 +54,26 @@ const router = createRouter({ name: 'purchase-edit', component: () => import('@/views/purchase/editorder.vue'), }, + { + path: 'work_order', + name: 'work-order-list', + component: () => import('@/views/work_order/WorkOrderList.vue'), + }, + { + path: 'work_order/add', + name: 'work-order-add', + component: () => import('@/views/work_order/AddEditWorkOrder.vue'), + }, + { + path: 'work_order/edit/:id', + name: 'work-order-edit', + component: () => import('@/views/work_order/AddEditWorkOrder.vue'), + }, + { + path: 'work_order/show/:id', + name: 'work-order-show', + component: () => import('@/views/work_order/ShowWorkOrder.vue'), + }, { path: 'warehouse', name: 'warehouse', diff --git a/frontend/ops_vue_js/src/views/purchase/PurchaseList.vue b/frontend/ops_vue_js/src/views/purchase/PurchaseList.vue index a0c7710..26effa1 100644 --- a/frontend/ops_vue_js/src/views/purchase/PurchaseList.vue +++ b/frontend/ops_vue_js/src/views/purchase/PurchaseList.vue @@ -17,6 +17,7 @@ const totalCount = ref(0) const pageSize = ref(10) const currentPage = ref(1) const statusFilter = ref('') +const searchQuery = ref('') const loading = ref(false) const statusOptions = [ @@ -45,6 +46,7 @@ async function fetchOrders() { try { const { errCode, data } = await purchaseApi.getOrders({ status: statusFilter.value, + search: searchQuery.value, entries: pageSize.value, page: currentPage.value, }) @@ -99,6 +101,11 @@ function handleJumpPageInput(e) { } } +function handleSearch() { + currentPage.value = 1 + fetchOrders() +} + onMounted(fetchOrders) @@ -112,7 +119,7 @@ onMounted(fetchOrders)
{{ t('work_order.not_found') }}
+{{ order?.Title || '-' }}
+{{ order.Description }}
++ {{ commit.Comment }} +
++ {{ t('work_order.commit_create') }} +
+ + +| No. | +{{ t('work_order.title') }} | +{{ t('work_order.created_at') }} | +{{ t('work_order.status') }} | +
|---|---|---|---|
| + + {{ t('message.loading') }} + | +|||
| + 暂无工单 + | +|||
| {{ order.ID }} | +{{ order.Title }} | +{{ formatDate(order.CreatedAt) }} | ++ + {{ t('work_order.status_' + order.CurrentStatus) || order.CurrentStatus }} + + | +