diff --git a/.workbuddy/memory/2026-04-23.md b/.workbuddy/memory/2026-04-23.md index e98b5a9..5217620 100644 --- a/.workbuddy/memory/2026-04-23.md +++ b/.workbuddy/memory/2026-04-23.md @@ -1,75 +1,31 @@ -# 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 下方展示关联的采购订单链接(点击跳转到采购详情页) - -## 今日功能迭代 - -**ConfirmDialog 组件 v-model 修复**: -- 组件使用 `v-if="modelValue"` 控制弹窗显示,但外部只用了 `v-if` 控制组件存在,没有传入 `modelValue` prop -- 修复:在所有使用 ConfirmDialog 的地方改用 `v-model="xxx"` 绑定 -- 涉及文件:ShowWorkOrder.vue、AddEditWorkOrder.vue、ShowOrder.vue - -**ShowWorkOrder.vue 进度删除功能**: -- 新增删除按钮(新样式:带边框和背景色的文字按钮) -- 每条 commit 加边框和背景色,便于区分 -- 最新状态不显示删除按钮 -- 权限判断:工单创建者 OR 进度创建者 OR 管理员 -- 删除后前端直接移除该 commit,保持滚动位置 - -**useDropzone v-model 问题修复**: -- useDropzone 组件没有实现 v-model,是通过 `return_files()` 方法暴露文件 -- 修复:添加 `ref="commitDropzoneRef"`,通过 `commitDropzoneRef.value?.return_files()` 获取文件 -- 只筛选 `is_upload === true` 的文件获取 hash - -**采购订单状态记录删除功能**: -- 后端:apiPurchase.go 新增 `/delete_commit` 接口,权限判断同工单 -- 前端:ShowOrder.vue 新增删除按钮,样式和逻辑同工单页面 -- 新增翻译:purchase.confirm_delete_commit +# 2026-04-23 工作日志 +## warehouse 模块前端开发 +### 今日完成 +- `apiWarehouse.go` 重写,参照 `apiWorkOrder.go` 模式 + - 修复 4 处图片绑定查询 `hash` 未使用的问题 + - 清理 6 处重复的 `AuthenticationAuthority` 调用 +- 容器列表页 (`WarehouseContainerList.vue`) — 根容器列表 + 统计卡片 +- 容器详情页 (`WarehouseContainerDetail.vue`) — 子容器/物品 Tab + 新增子容器弹窗 +- 新增物品独立页 (`WarehouseAddItem.vue`) + 路由注册 + - 路由: `/warehouse/container/:id/add-item` + - 物品 Tab 按钮从弹窗改为跳转独立页面 + - 使用 `useDropzone` 组件上传图片 +- 物品详情页 (`WarehouseItemDetail.vue`) — 物品信息 + 移动历史/关联工单 Tab +- 物品列表总览 (`WarehouseItemList.vue`) + - 路由: `/warehouse/items`,侧边栏入口「物品总览」 + - 统计卡片(总数/已入库/未入库) + - 表格:名称/序列号/数量/位置/创建时间 + 跳转详情/删除 +- 补充 i18n key(中/英双语) +### 踩坑 +- 后端 `TabWarehouseItem.SerialNumber` JSON 字段名为 `serial_number`(小写),前端须对应 +- `useDropzone` 组件通过 `dropzoneRef.value.return_files()` 获取已上传文件的 hash 数组 +- `RouterLink` 在此项目为全局组件,无需 import +- `watch` 需要显式 import:`import { ref, reactive, computed, onMounted, watch } from 'vue'` +- **项目没有 daisyUI**,所有样式均用纯 Tailwind CSS v4 实现 + - 不用 `btn`、`tabs`、`tab`、`input-bordered`、`table`、`modal`、`join`、`form-control`、`badge`、`card` 等 daisyUI 类 + - 用 Tailwind 自定义样式:`rounded-xl border border-gray-200 bg-white shadow dark:border-dk-muted dark:bg-dk-card` + - 加载动画用自定义 SVG spinner,不用 `loading loading-spinner` + - 弹窗用 `` + Tailwind 固定定位,不用 daisyUI `modal` diff --git a/backend/my_work/main.go b/backend/my_work/main.go index d193160..44b74ec 100644 --- a/backend/my_work/main.go +++ b/backend/my_work/main.go @@ -71,6 +71,7 @@ func main() { routers.ApiScheduleInit() routers.ApiPurchaseInit() routers.ApiWorkOrderInit() + routers.ApiWarehouseInit() //创建必要目录 for _, path := range models.ConfigsFile.Pahts { diff --git a/backend/my_work/models/warehouse.go b/backend/my_work/models/warehouse.go new file mode 100644 index 0000000..f16bc91 --- /dev/null +++ b/backend/my_work/models/warehouse.go @@ -0,0 +1,3 @@ +package models + +// 仓库模块所有结构定义在 routers/apiWarehouse.go diff --git a/backend/my_work/routers/api.go b/backend/my_work/routers/api.go index cc4f8f1..804809a 100644 --- a/backend/my_work/routers/api.go +++ b/backend/my_work/routers/api.go @@ -56,9 +56,10 @@ func ApiRoot(r *gin.RouterGroup) { ApiPurchase(r.Group("/purchase")) ApiSchedule(r.Group("/schedule")) ApiWorkOrder(r.Group("/work_order")) + ApiWarehouse(r.Group("/warehouse")) r.GET("/", func(ctx *gin.Context) { ReturnJson(ctx, "apiOK", gin.H{ - "isOpsApiRoot":true, + "isOpsApiRoot": true, }) }) diff --git a/backend/my_work/routers/apiWarehouse.go b/backend/my_work/routers/apiWarehouse.go new file mode 100644 index 0000000..a41f8d7 --- /dev/null +++ b/backend/my_work/routers/apiWarehouse.go @@ -0,0 +1,830 @@ +package routers + +import ( + "encoding/json" + "ops/models" + "strconv" + "time" + + "github.com/gin-gonic/gin" +) + +// ---------- 数据表结构 ---------- + +type TabWarehouseContainer struct { + ID uint `gorm:"primaryKey"` + Title string `gorm:"size:255;not null;comment:容器名"` + Remark string `gorm:"type:text;comment:描述"` + CreatedAt string `gorm:"size:20;comment:创建日期"` + CreatorID uint `gorm:"not null;index;comment:创建者id"` + ParentID *uint `gorm:"index;comment:父容器id,nil=顶级"` + ItemCount int `gorm:"default:0;comment:直接子物品数量"` + ChildCount int `gorm:"default:0;comment:子容器数量"` +} + +type TabWarehouseItem struct { + ID uint `gorm:"primaryKey"` + Name string `gorm:"size:255;not null;comment:物品名"` + SerialNumber string `gorm:"size:255;comment:序列号"` + Remark string `gorm:"type:text;comment:描述"` + Quantity int `gorm:"default:1;comment:数量"` + CreatedAt string `gorm:"size:20;comment:创建日期"` + CreatorID uint `gorm:"not null;index;comment:创建者id"` + ContainerID *uint `gorm:"index;comment:所属容器id,nil=未入库"` +} + +type TabWarehouseContainerFileBind struct { + ID uint `gorm:"primaryKey"` + ContainerID uint `gorm:"not null;index;comment:关联容器id"` + FileID uint `gorm:"not null;comment:关联文件id"` + CreatorID uint `gorm:"not null;comment:上传人id"` + CreatedAt time.Time `gorm:"type:datetime;autoCreateTime"` +} + +type TabWarehouseItemFileBind struct { + ID uint `gorm:"primaryKey"` + ItemID uint `gorm:"not null;index;comment:关联物品id"` + FileID uint `gorm:"not null;comment:关联文件id"` + CreatorID uint `gorm:"not null;comment:上传人id"` + CreatedAt time.Time `gorm:"type:datetime;autoCreateTime"` +} + +type TabWarehouseItemCommit struct { + ID uint `gorm:"primaryKey"` + ItemID uint `gorm:"not null;index;comment:关联物品id"` + UserID uint `gorm:"not null;comment:操作人id"` + OldContainer *uint `gorm:"index;comment:原容器id"` + NewContainer *uint `gorm:"index;comment:新容器id"` + Remark string `gorm:"type:text;comment:备注"` + IP string `gorm:"size:50;comment:操作IP"` + CreatedAt time.Time `gorm:"type:datetime;autoCreateTime"` +} + +type TabWarehouseLog struct { + ID uint `gorm:"primaryKey"` + EntityType string `gorm:"size:50;not null;index;comment:操作对象类型"` + EntityID uint `gorm:"not null;index;comment:操作对象id"` + UserID uint `gorm:"not null;index;comment:操作人id"` + ActionType string `gorm:"size:50;not null;comment:操作类型: create update delete move 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 TabWarehouseItemWorkOrderBind struct { + ID uint `gorm:"primaryKey"` + ItemID uint `gorm:"not null;index;comment:关联物品id"` + WorkOrderID uint `gorm:"not null;index;comment:关联工单id"` + Remark string `gorm:"size:500;comment:备注"` + CreatorID uint `gorm:"not null;comment:绑定人id"` + CreatedAt time.Time `gorm:"type:datetime;autoCreateTime"` +} + +// ---------- 初始化 ---------- + +func ApiWarehouseInit() { + models.DB.AutoMigrate( + &TabWarehouseContainer{}, + &TabWarehouseItem{}, + &TabWarehouseContainerFileBind{}, + &TabWarehouseItemFileBind{}, + &TabWarehouseItemCommit{}, + &TabWarehouseLog{}, + &TabWarehouseItemWorkOrderBind{}, + ) +} + +// ---------- 路由注册 ---------- + +func ApiWarehouse(r *gin.RouterGroup) { + + // 新增容器 + r.POST("/add_container", func(ctx *gin.Context) { + isAuth, user, data := AuthenticationAuthority(ctx) + if !isAuth { + ReturnJson(ctx, "userCookieError", nil) + return + } + + type FromAdd struct { + Title string `json:"title"` + Remark string `json:"remark"` + ParentID *uint `json:"parent_id"` + 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 + } + } + + // 检查嵌套层级不超过5层 + if from.ParentID != nil { + var parent TabWarehouseContainer + if err := models.DB.First(&parent, *from.ParentID).Error; err != nil { + ReturnJson(ctx, "parent_not_found", nil) + return + } + depth := 0 + curID := *from.ParentID + for depth < 100 { + var c TabWarehouseContainer + if err := models.DB.First(&c, curID).Error; err != nil { + break + } + if c.ParentID == nil { + break + } + curID = *c.ParentID + depth++ + } + if depth >= 4 { // 新容器将落在第5层 + ReturnJson(ctx, "max_depth_exceeded", nil) + return + } + } + + c := TabWarehouseContainer{ + Title: from.Title, + Remark: from.Remark, + CreatedAt: strconv.FormatInt(time.Now().Unix(), 10), + CreatorID: user.ID, + ParentID: from.ParentID, + } + models.DB.Create(&c) + + // 绑定图片 + for _, hash := range from.Photos { + var findFile TabFileInfo_ + if models.DB.Where(&TabFileInfo_{Sha256: hash, Type: "image"}).First(&findFile).Error == nil { + models.DB.Create(&TabWarehouseContainerFileBind{ + ContainerID: c.ID, + FileID: findFile.ID, + CreatorID: user.ID, + }) + } + } + + // 父容器的 ChildCount +1 + if from.ParentID != nil { + models.DB.Model(&TabWarehouseContainer{}).Where("id = ?", *from.ParentID).Update("child_count", models.DB.Raw("child_count + 1")) + } + + // 写操作日志 + newContent, _ := json.Marshal(from) + models.DB.Create(&TabWarehouseLog{ + EntityType: "container", + EntityID: c.ID, + UserID: user.ID, + ActionType: "create", + NewContent: string(newContent), + IP: ctx.ClientIP(), + }) + + ReturnJson(ctx, "apiOK", gin.H{"id": c.ID}) + }) + + // 编辑容器 + r.POST("/update_container", 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"` + Remark string `json:"remark"` + 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 c TabWarehouseContainer + if err := models.DB.Where("id = ?", from.ID).First(&c).Error; err != nil { + ReturnJson(ctx, "container_not_found", nil) + return + } + + oldContent, _ := json.Marshal(c) + models.DB.Model(&c).Updates(map[string]interface{}{ + "title": from.Title, + "remark": from.Remark, + }) + + // 重建图片绑定 + models.DB.Where("container_id = ?", from.ID).Delete(&TabWarehouseContainerFileBind{}) + for _, hash := range from.Photos { + var findFile TabFileInfo_ + if models.DB.Where(&TabFileInfo_{Sha256: hash, Type: "image"}).First(&findFile).Error == nil { + models.DB.Create(&TabWarehouseContainerFileBind{ + ContainerID: from.ID, + FileID: findFile.ID, + CreatorID: user.ID, + }) + } + } + + newContent, _ := json.Marshal(from) + models.DB.Create(&TabWarehouseLog{ + EntityType: "container", + EntityID: from.ID, + UserID: user.ID, + ActionType: "update", + OldContent: string(oldContent), + NewContent: string(newContent), + IP: ctx.ClientIP(), + }) + + ReturnJson(ctx, "apiOK", nil) + }) + + // 删除容器 + r.POST("/delete_container", 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 c TabWarehouseContainer + if err := models.DB.Where("id = ?", from.ID).First(&c).Error; err != nil { + ReturnJson(ctx, "container_not_found", nil) + return + } + + // 检查是否有子容器或物品 + if c.ChildCount > 0 || c.ItemCount > 0 { + ReturnJson(ctx, "container_not_empty", nil) + return + } + + models.DB.Where("container_id = ?", from.ID).Delete(&TabWarehouseContainerFileBind{}) + + // 父容器 ChildCount -1 + if c.ParentID != nil { + models.DB.Model(&TabWarehouseContainer{}).Where("id = ?", *c.ParentID).Update("child_count", models.DB.Raw("child_count - 1")) + } + + oldContent, _ := json.Marshal(c) + models.DB.Create(&TabWarehouseLog{ + EntityType: "container", + EntityID: from.ID, + UserID: user.ID, + ActionType: "delete", + OldContent: string(oldContent), + IP: ctx.ClientIP(), + }) + + models.DB.Delete(&c) + + ReturnJson(ctx, "apiOK", nil) + }) + + // 获取容器列表 + r.POST("/list_container", func(ctx *gin.Context) { + isAuth, _, data := AuthenticationAuthority(ctx) + if !isAuth { + ReturnJson(ctx, "userCookieError", nil) + return + } + + type FromList struct { + Search string `json:"search"` + ParentID *uint `json:"parent_id"` + 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(&TabWarehouseContainer{}) + if from.Search != "" { + query = query.Where("title LIKE ?", "%"+from.Search+"%") + } + if from.ParentID != nil { + query = query.Where("parent_id = ?", *from.ParentID) + } else { + query = query.Where("parent_id IS NULL") + } + query.Count(&count) + + var containers []TabWarehouseContainer + query.Order("created_at DESC"). + Offset(from.Entries * (from.Page - 1)). + Limit(from.Entries). + Find(&containers) + + ReturnJson(ctx, "apiOK", gin.H{ + "all_count": count, + "containers": containers, + }) + }) + + // 获取容器详情 + r.POST("/get_container", func(ctx *gin.Context) { + isAuth, _, 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 c TabWarehouseContainer + if err := models.DB.Where("id = ?", from.ID).First(&c).Error; err != nil { + ReturnJson(ctx, "container_not_found", nil) + return + } + + // 关联图片 + var binds []TabWarehouseContainerFileBind + models.DB.Where("container_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) + } + + ReturnJson(ctx, "apiOK", gin.H{ + "container": c, + "photos": files, + }) + }) + + // 新增物品 + r.POST("/add_item", func(ctx *gin.Context) { + isAuth, user, data := AuthenticationAuthority(ctx) + if !isAuth { + ReturnJson(ctx, "userCookieError", nil) + return + } + + type FromAdd struct { + Name string `json:"name"` + SerialNumber string `json:"serial_number"` + Remark string `json:"remark"` + Quantity int `json:"quantity"` + ContainerID *uint `json:"container_id"` + Photos []string `json:"photos"` + } + var from FromAdd + if err := decodeJSON(data, &from); err != nil || from.Name == "" { + ReturnJson(ctx, "jsonErr", nil) + return + } + + // 校验图片哈希 + for _, hash := range from.Photos { + if models.IsContainsSpecialChar(hash) { + ReturnJson(ctx, "photo_hash_invalid", nil) + return + } + } + + quantity := from.Quantity + if quantity <= 0 { + quantity = 1 + } + + item := TabWarehouseItem{ + Name: from.Name, + SerialNumber: from.SerialNumber, + Remark: from.Remark, + Quantity: quantity, + CreatedAt: strconv.FormatInt(time.Now().Unix(), 10), + CreatorID: user.ID, + ContainerID: from.ContainerID, + } + models.DB.Create(&item) + + // 绑定图片 + for _, hash := range from.Photos { + var findFile TabFileInfo_ + if models.DB.Where(&TabFileInfo_{Sha256: hash, Type: "image"}).First(&findFile).Error == nil { + models.DB.Create(&TabWarehouseItemFileBind{ + ItemID: item.ID, + FileID: findFile.ID, + CreatorID: user.ID, + }) + } + } + + // 所属容器的 ItemCount +1 + if from.ContainerID != nil { + models.DB.Model(&TabWarehouseContainer{}).Where("id = ?", *from.ContainerID).Update("item_count", models.DB.Raw("item_count + 1")) + } + + // 写操作日志 + newContent, _ := json.Marshal(from) + models.DB.Create(&TabWarehouseLog{ + EntityType: "item", + EntityID: item.ID, + UserID: user.ID, + ActionType: "create", + NewContent: string(newContent), + IP: ctx.ClientIP(), + }) + + ReturnJson(ctx, "apiOK", gin.H{"id": item.ID}) + }) + + // 编辑物品 + r.POST("/update_item", func(ctx *gin.Context) { + isAuth, user, data := AuthenticationAuthority(ctx) + if !isAuth { + ReturnJson(ctx, "userCookieError", nil) + return + } + + type FromUpdate struct { + ID uint `json:"id"` + Name string `json:"name"` + SerialNumber string `json:"serial_number"` + Remark string `json:"remark"` + Quantity int `json:"quantity"` + Photos []string `json:"photos"` + } + var from FromUpdate + if err := decodeJSON(data, &from); err != nil || from.ID == 0 || from.Name == "" { + ReturnJson(ctx, "jsonErr", nil) + return + } + + // 校验图片哈希 + for _, hash := range from.Photos { + if models.IsContainsSpecialChar(hash) { + ReturnJson(ctx, "photo_hash_invalid", nil) + return + } + } + + var item TabWarehouseItem + if err := models.DB.Where("id = ?", from.ID).First(&item).Error; err != nil { + ReturnJson(ctx, "item_not_found", nil) + return + } + + oldContent, _ := json.Marshal(item) + models.DB.Model(&item).Updates(map[string]interface{}{ + "name": from.Name, + "serial_number": from.SerialNumber, + "remark": from.Remark, + "quantity": from.Quantity, + }) + + // 重建图片绑定 + models.DB.Where("item_id = ?", from.ID).Delete(&TabWarehouseItemFileBind{}) + for _, hash := range from.Photos { + var findFile TabFileInfo_ + if models.DB.Where(&TabFileInfo_{Sha256: hash, Type: "image"}).First(&findFile).Error == nil { + models.DB.Create(&TabWarehouseItemFileBind{ + ItemID: from.ID, + FileID: findFile.ID, + CreatorID: user.ID, + }) + } + } + + newContent, _ := json.Marshal(from) + models.DB.Create(&TabWarehouseLog{ + EntityType: "item", + EntityID: from.ID, + UserID: user.ID, + ActionType: "update", + OldContent: string(oldContent), + NewContent: string(newContent), + IP: ctx.ClientIP(), + }) + + ReturnJson(ctx, "apiOK", nil) + }) + + // 删除物品 + r.POST("/delete_item", 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 item TabWarehouseItem + if err := models.DB.Where("id = ?", from.ID).First(&item).Error; err != nil { + ReturnJson(ctx, "item_not_found", nil) + return + } + + // 所属容器 ItemCount -1 + if item.ContainerID != nil { + models.DB.Model(&TabWarehouseContainer{}).Where("id = ?", *item.ContainerID).Update("item_count", models.DB.Raw("item_count - 1")) + } + + // 删除关联 + models.DB.Where("item_id = ?", from.ID).Delete(&TabWarehouseItemFileBind{}) + models.DB.Where("item_id = ?", from.ID).Delete(&TabWarehouseItemCommit{}) + models.DB.Where("item_id = ?", from.ID).Delete(&TabWarehouseItemWorkOrderBind{}) + + oldContent, _ := json.Marshal(item) + models.DB.Create(&TabWarehouseLog{ + EntityType: "item", + EntityID: from.ID, + UserID: user.ID, + ActionType: "delete", + OldContent: string(oldContent), + IP: ctx.ClientIP(), + }) + + models.DB.Delete(&item) + + ReturnJson(ctx, "apiOK", nil) + }) + + // 获取物品列表 + r.POST("/list_item", func(ctx *gin.Context) { + isAuth, _, data := AuthenticationAuthority(ctx) + if !isAuth { + ReturnJson(ctx, "userCookieError", nil) + return + } + + type FromList struct { + Search string `json:"search"` + ContainerID *uint `json:"container_id"` + 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(&TabWarehouseItem{}) + if from.Search != "" { + query = query.Where("name LIKE ? OR serial_number LIKE ? OR remark LIKE ?", + "%"+from.Search+"%", "%"+from.Search+"%", "%"+from.Search+"%") + } + if from.ContainerID != nil { + query = query.Where("container_id = ?", *from.ContainerID) + } + query.Count(&count) + + var items []TabWarehouseItem + query.Order("created_at DESC"). + Offset(from.Entries * (from.Page - 1)). + Limit(from.Entries). + Find(&items) + + ReturnJson(ctx, "apiOK", gin.H{ + "all_count": count, + "items": items, + }) + }) + + // 获取物品详情 + r.POST("/get_item", func(ctx *gin.Context) { + isAuth, _, 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 item TabWarehouseItem + if err := models.DB.Where("id = ?", from.ID).First(&item).Error; err != nil { + ReturnJson(ctx, "item_not_found", nil) + return + } + + // 关联图片 + var binds []TabWarehouseItemFileBind + models.DB.Where("item_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) + } + + // 移动历史 + var commits []TabWarehouseItemCommit + models.DB.Where("item_id = ?", from.ID).Order("created_at DESC").Find(&commits) + + // 关联工单 + var woBinds []TabWarehouseItemWorkOrderBind + models.DB.Where("item_id = ?", from.ID).Find(&woBinds) + + type WOInfo struct { + ID uint `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + } + var workOrders []WOInfo + for _, b := range woBinds { + var wo TabWorkOrder + if models.DB.Where("id = ?", b.WorkOrderID).First(&wo).Error == nil { + workOrders = append(workOrders, WOInfo{ID: wo.ID, Title: wo.Title, Status: wo.CurrentStatus}) + } + } + + ReturnJson(ctx, "apiOK", gin.H{ + "item": item, + "photos": files, + "commits": commits, + "work_orders": workOrders, + }) + }) + + // 移动物品到其他容器 + r.POST("/move_item", func(ctx *gin.Context) { + isAuth, user, data := AuthenticationAuthority(ctx) + if !isAuth { + ReturnJson(ctx, "userCookieError", nil) + return + } + + type FromMove struct { + ItemID uint `json:"item_id"` + NewContainer *uint `json:"new_container"` + Remark string `json:"remark"` + } + var from FromMove + if err := decodeJSON(data, &from); err != nil || from.ItemID == 0 { + ReturnJson(ctx, "jsonErr", nil) + return + } + + var item TabWarehouseItem + if err := models.DB.Where("id = ?", from.ItemID).First(&item).Error; err != nil { + ReturnJson(ctx, "item_not_found", nil) + return + } + + oldContainer := item.ContainerID + + // 同一容器无需操作 + if ptrEqUint(oldContainer, from.NewContainer) { + ReturnJson(ctx, "apiOK", nil) + return + } + + // 旧容器 ItemCount -1 + if oldContainer != nil { + models.DB.Model(&TabWarehouseContainer{}).Where("id = ?", *oldContainer).Update("item_count", models.DB.Raw("item_count - 1")) + } + + // 新容器 ItemCount +1 + if from.NewContainer != nil { + models.DB.Model(&TabWarehouseContainer{}).Where("id = ?", *from.NewContainer).Update("item_count", models.DB.Raw("item_count + 1")) + } + + // 更新物品容器 + item.ContainerID = from.NewContainer + models.DB.Save(&item) + + // 记录移动日志 + models.DB.Create(&TabWarehouseItemCommit{ + ItemID: from.ItemID, + UserID: user.ID, + OldContainer: oldContainer, + NewContainer: from.NewContainer, + Remark: from.Remark, + IP: ctx.ClientIP(), + }) + + // 写通用操作日志 + models.DB.Create(&TabWarehouseLog{ + EntityType: "item", + EntityID: from.ItemID, + UserID: user.ID, + ActionType: "move", + OldContent: ptrStrUint(oldContainer), + NewContent: ptrStrUint(from.NewContainer), + IP: ctx.ClientIP(), + Remark: from.Remark, + }) + + ReturnJson(ctx, "apiOK", nil) + }) + + // 获取仓库统计 + r.POST("/count", func(ctx *gin.Context) { + isAuth, _, _ := AuthenticationAuthority(ctx) + if !isAuth { + ReturnJson(ctx, "userCookieError", nil) + return + } + + type WCount struct { + ContainerTotal int64 `json:"container_total"` + ItemTotal int64 `json:"item_total"` + UnstoredItems int64 `json:"unstored_items"` + } + var count WCount + models.DB.Model(&TabWarehouseContainer{}).Count(&count.ContainerTotal) + models.DB.Model(&TabWarehouseItem{}).Count(&count.ItemTotal) + models.DB.Model(&TabWarehouseItem{}).Where("container_id IS NULL").Count(&count.UnstoredItems) + + ReturnJson(ctx, "apiOK", gin.H{ + "container_total": count.ContainerTotal, + "item_total": count.ItemTotal, + "unstored_items": count.UnstoredItems, + }) + }) +} + +// ---------- 辅助函数 ---------- + +func ptrEqUint(a, b *uint) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return *a == *b +} + +func ptrStrUint(p *uint) string { + if p == nil { + return "nil" + } + return strconv.FormatUint(uint64(*p), 10) +} diff --git a/frontend/ops_vue_js/src/api/warehouse.js b/frontend/ops_vue_js/src/api/warehouse.js new file mode 100644 index 0000000..34864e0 --- /dev/null +++ b/frontend/ops_vue_js/src/api/warehouse.js @@ -0,0 +1,68 @@ +import { api } from './index' + +export const warehouseApi = { + /** 获取容器列表 */ + getContainers(params = {}) { + return api.post('/warehouse/list_container', params) + }, + + /** 获取容器详情(含图片) */ + getContainer(id) { + return api.post('/warehouse/get_container', { id }) + }, + + /** 新增容器 */ + addContainer(data) { + return api.post('/warehouse/add_container', data) + }, + + /** 编辑容器 */ + updateContainer(data) { + return api.post('/warehouse/update_container', data) + }, + + /** 删除容器 */ + deleteContainer(id) { + return api.post('/warehouse/delete_container', { id }) + }, + + /** 获取仓库统计 */ + getCount() { + return api.post('/warehouse/count', {}) + }, + + /** 获取物品列表 */ + getItems(params = {}) { + return api.post('/warehouse/list_item', params) + }, + + /** 新增物品 */ + addItem(data) { + return api.post('/warehouse/add_item', data) + }, + + /** 获取物品详情 */ + getItem(id) { + return api.post('/warehouse/get_item', { id }) + }, + + /** 编辑物品 */ + updateItem(data) { + return api.post('/warehouse/update_item', data) + }, + + /** 删除物品 */ + deleteItem(id) { + return api.post('/warehouse/delete_item', { id }) + }, + + /** 移动物品 */ + moveItem(data) { + return api.post('/warehouse/move_item', data) + }, + + /** 获取容器列表(用于移动目标选择) */ + getContainers(params = {}) { + return api.post('/warehouse/list_container', params) + }, +} diff --git a/frontend/ops_vue_js/src/components/AppHeader.vue b/frontend/ops_vue_js/src/components/AppHeader.vue index a609931..3fa6af6 100644 --- a/frontend/ops_vue_js/src/components/AppHeader.vue +++ b/frontend/ops_vue_js/src/components/AppHeader.vue @@ -51,7 +51,8 @@ const navItems = computed(() => [ { 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" }, + { label: t("appname.warehouse"), to: "/warehouse/container" }, + { label: t("appname.warehouse_items"), to: "/warehouse/item" }, ]); diff --git a/frontend/ops_vue_js/src/i18n/en.json b/frontend/ops_vue_js/src/i18n/en.json index 92c6909..db4c89b 100644 --- a/frontend/ops_vue_js/src/i18n/en.json +++ b/frontend/ops_vue_js/src/i18n/en.json @@ -23,6 +23,7 @@ "schedule": "Schedule", "purchase": "Purchase", "warehouse": "Warehouse", + "warehouse_items": "Items Overview", "work_order": "Work Order" }, "tagadder": { @@ -151,6 +152,64 @@ "submit": "Submit", "save_changes": "Save Changes" }, + "warehouse": { + "container_list": "Container List", + "container_detail": "Container Detail", + "item_list": "Item List", + "add_container": "Add Container", + "edit_container": "Edit Container", + "container_name": "Container Name", + "title_placeholder": "Enter container name", + "remark": "Remark", + "remark_placeholder": "Enter remark (optional)", + "created_at": "Created At", + "created_by": "Created By", + "child_containers": "Sub-Containers", + "items": "Items", + "actions": "Actions", + "view_items": "View", + "edit": "Edit", + "delete": "Delete", + "search_placeholder": "Search", + "no_containers": "No containers found", + "no_items": "No items found", + "container_count": "Containers", + "item_count": "Items", + "unstored_items": "Unstored", + "title_required": "Container name is required", + "delete_confirm_title": "Delete Container", + "delete_confirm_msg": "Are you sure you want to delete \"{name}\"? This cannot be undone.", + "cannot_delete_nonempty": "Cannot delete a container that has items or sub-containers", + "confirm_delete": "Are you sure you want to delete this container? This action cannot be undone.", + "add_item": "Add Item", + "edit_item": "Edit Item", + "item_name": "Item Name", + "item_name_placeholder": "Enter item name", + "serial_number": "Serial Number", + "serial_number_placeholder": "Enter serial number (optional)", + "quantity": "Quantity", + "item_name_required": "Item name is required", + "back_to_list": "Back to List", + "root": "Warehouse", + "item_detail": "Item Detail", + "edit_item": "Edit Item", + "move_item": "Move Item", + "move_history": "Move History", + "work_orders": "Work Orders", + "no_move_history": "No move history", + "no_work_orders": "No related work orders", + "current_location": "Current Location", + "target_container": "Target Container", + "search_container": "Search container...", + "confirm_move": "Confirm Move", + "move_remark_placeholder": "Move remark (optional)", + "location": "Location", + "delete_item_title": "Delete Item", + "delete_item_msg": "Are you sure you want to delete item \"{name}\"? This action cannot be undone.", + "items_in_containers": "Stored", + "total_items": "{count} items", + "search_item_placeholder": "Search by name or serial number" + }, "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 bb8203c..ec66a29 100644 --- a/frontend/ops_vue_js/src/i18n/zh-CN.json +++ b/frontend/ops_vue_js/src/i18n/zh-CN.json @@ -23,6 +23,7 @@ "schedule": "日程", "purchase": "采购", "warehouse": "仓库", + "warehouse_items": "物品总览", "work_order": "工单" }, "tagadder": { @@ -151,6 +152,64 @@ "submit": "提交", "save_changes": "保存修改" }, + "warehouse": { + "container_list": "容器列表", + "container_detail": "容器详情", + "item_list": "物品列表", + "add_container": "新增容器", + "edit_container": "编辑容器", + "container_name": "容器名称", + "title_placeholder": "输入容器名称", + "remark": "备注", + "remark_placeholder": "输入备注(可选)", + "created_at": "创建日期", + "created_by": "创建人", + "child_containers": "子容器", + "items": "物品", + "actions": "操作", + "view_items": "查看", + "edit": "编辑", + "delete": "删除", + "search_placeholder": "搜索", + "no_containers": "暂无容器", + "no_items": "暂无物品", + "container_count": "容器数", + "item_count": "物品数", + "unstored_items": "未入库", + "title_required": "容器名称不能为空", + "delete_confirm_title": "删除容器", + "delete_confirm_msg": "确定要删除容器「{name}」吗?此操作不可撤销。", + "cannot_delete_nonempty": "容器内含有物品或子容器,无法删除", + "confirm_delete": "确定要删除此容器吗?此操作不可撤销。", + "add_item": "新增物品", + "edit_item": "编辑物品", + "item_name": "物品名称", + "item_name_placeholder": "输入物品名称", + "serial_number": "序列号", + "serial_number_placeholder": "输入序列号(可选)", + "quantity": "数量", + "item_name_required": "物品名称不能为空", + "back_to_list": "返回列表", + "root": "仓库", + "item_detail": "物品详情", + "edit_item": "编辑物品", + "move_item": "移动物品", + "move_history": "移动历史", + "work_orders": "关联工单", + "no_move_history": "暂无移动记录", + "no_work_orders": "暂无关联工单", + "current_location": "当前位置", + "target_container": "目标容器", + "search_container": "搜索容器...", + "confirm_move": "确认移动", + "move_remark_placeholder": "输入移动备注(可选)", + "location": "位置", + "delete_item_title": "删除物品", + "delete_item_msg": "确定要删除物品「{name}」吗?此操作不可撤销。", + "items_in_containers": "已入库", + "total_items": "共 {count} 条", + "search_item_placeholder": "搜索物品名称或序列号" + }, "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 576c88a..0135859 100644 --- a/frontend/ops_vue_js/src/router/index.js +++ b/frontend/ops_vue_js/src/router/index.js @@ -75,9 +75,29 @@ const router = createRouter({ component: () => import('@/views/work_order/ShowWorkOrder.vue'), }, { - path: 'warehouse', + path: 'warehouse/container', name: 'warehouse', - component: () => import('@/views/WarehouseView.vue'), + component: () => import('@/views/warehouse/WarehouseContainerList.vue'), + }, + { + path: 'warehouse/container/:id', + name: 'warehouse-container-detail', + component: () => import('@/views/warehouse/WarehouseContainerDetail.vue'), + }, + { + path: 'warehouse/container/:id/add-item', + name: 'warehouse-add-item', + component: () => import('@/views/warehouse/WarehouseAddItem.vue'), + }, + { + path: 'warehouse/item', + name: 'warehouse-item-list', + component: () => import('@/views/warehouse/WarehouseItemList.vue'), + }, + { + path: 'warehouse/item/:id', + name: 'warehouse-item-detail', + component: () => import('@/views/warehouse/WarehouseItemDetail.vue'), }, { path: 'admin', @@ -140,7 +160,7 @@ router.beforeEach((to) => { const userStore = useUserStore() // 不需要登录的页面 - const publicPages = ['/', '/login', '/register', '/forgot_password', '/schedule','/warehouse', '/404'] + const publicPages = ['/', '/login', '/register', '/forgot_password', '/schedule', '/warehouse/container', '/warehouse/item', '/404'] if (publicPages.includes(to.path)) return true // 未登录 → 跳转登录 diff --git a/frontend/ops_vue_js/src/views/warehouse/WarehouseAddItem.vue b/frontend/ops_vue_js/src/views/warehouse/WarehouseAddItem.vue new file mode 100644 index 0000000..f389287 --- /dev/null +++ b/frontend/ops_vue_js/src/views/warehouse/WarehouseAddItem.vue @@ -0,0 +1,206 @@ + + + diff --git a/frontend/ops_vue_js/src/views/warehouse/WarehouseContainerDetail.vue b/frontend/ops_vue_js/src/views/warehouse/WarehouseContainerDetail.vue new file mode 100644 index 0000000..f22e934 --- /dev/null +++ b/frontend/ops_vue_js/src/views/warehouse/WarehouseContainerDetail.vue @@ -0,0 +1,723 @@ + + + diff --git a/frontend/ops_vue_js/src/views/warehouse/WarehouseContainerList.vue b/frontend/ops_vue_js/src/views/warehouse/WarehouseContainerList.vue new file mode 100644 index 0000000..1442185 --- /dev/null +++ b/frontend/ops_vue_js/src/views/warehouse/WarehouseContainerList.vue @@ -0,0 +1,498 @@ + + + diff --git a/frontend/ops_vue_js/src/views/warehouse/WarehouseItemDetail.vue b/frontend/ops_vue_js/src/views/warehouse/WarehouseItemDetail.vue new file mode 100644 index 0000000..ea9918a --- /dev/null +++ b/frontend/ops_vue_js/src/views/warehouse/WarehouseItemDetail.vue @@ -0,0 +1,656 @@ + + + diff --git a/frontend/ops_vue_js/src/views/warehouse/WarehouseItemList.vue b/frontend/ops_vue_js/src/views/warehouse/WarehouseItemList.vue new file mode 100644 index 0000000..605975c --- /dev/null +++ b/frontend/ops_vue_js/src/views/warehouse/WarehouseItemList.vue @@ -0,0 +1,353 @@ + + +