From 1b1ec7f64d9ba4806f06ea316c12aa850bd500f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=96=87=E5=B3=B0?= Date: Thu, 23 Apr 2026 22:52:55 +0800 Subject: [PATCH] =?UTF-8?q?up=E5=8A=9F=E8=83=BD=E5=9F=BA=E6=9C=AC=E5=AE=8C?= =?UTF-8?q?=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .workbuddy/memory/2026-04-23.md | 60 ++--- backend/my_work/routers/apiWarehouse.go | 101 ++++++-- .../ops_vue_js/src/components/useDropzone.vue | 29 ++- frontend/ops_vue_js/src/router/index.js | 5 + .../warehouse/WarehouseContainerDetail.vue | 43 +++- .../views/warehouse/WarehouseItemDetail.vue | 133 +--------- .../src/views/warehouse/WarehouseItemEdit.vue | 243 ++++++++++++++++++ .../src/views/warehouse/WarehouseItemList.vue | 11 +- .../src/views/warehouse/WarehouseOverview.vue | 70 +++-- 9 files changed, 467 insertions(+), 228 deletions(-) create mode 100644 frontend/ops_vue_js/src/views/warehouse/WarehouseItemEdit.vue diff --git a/.workbuddy/memory/2026-04-23.md b/.workbuddy/memory/2026-04-23.md index 5d8f23c..872c209 100644 --- a/.workbuddy/memory/2026-04-23.md +++ b/.workbuddy/memory/2026-04-23.md @@ -1,35 +1,31 @@ -# 2026-04-23 工作日志 +# 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 -- **合并页面 (`WarehouseOverview.vue`)** — 容器+物品合并到一个页面 - - 路由 `/warehouse/container` 直接渲染此页面 - - 顶部 3 格统计卡片(容器数/物品数/未入库) - - Tab 切换「容器」/「物品」 - - 容器 Tab:搜索+表格+分页+新增/编辑弹窗+删除确认 - - 物品 Tab:搜索(400ms防抖)+表格+分页+删除确认 - - 删除了 `/warehouse/item` 独立路由和侧边栏「物品总览」入口 -- 补充 i18n key(中/英双语) +**涉及文件:** +- `frontend/ops_vue_js/src/views/warehouse/WarehouseItemDetail.vue` — 编辑弹窗增加 `useDropzone` 组件,支持加载已有图片、上传新图片、删除图片 +- `frontend/ops_vue_js/src/components/useDropzone.vue` — 导出 `loadInitialFiles` 方法供外部调用 -### 踩坑 -- 后端 `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` - - **弹窗用 `` + `v-if` + `@click.self` 关闭,不用 ``**,`` 的 `:open` 属性在某些场景不会正确响应 false -- **批量修改 Vue 模板后务必检查缩进**:逐块替换时外层 div 的闭合标签容易被吞,造成 "Element is missing end tag" 错误 +**实现方式:** +- 编辑弹窗中新增 `editDropzoneRef` ref,绑定 `useDropzone` 组件 +- `openEdit()` 时调用 `loadInitialFiles()` 刷新初始文件 +- `submitEdit()` 时从 dropzone 获取所有图片哈希(包含新上传和已存在的),一并传给 `updateItem` API +- 后端 `update_item` API 已支持 `photos` 字段,会重建图片绑定 + +**关键代码片段:** +```javascript +// 提交时获取所有图片哈希 +const photos = getEditPhotoHashes() +warehouseApi.updateItem({ id, name, serial_number, remark, quantity, photos }) +``` + +## 物品编辑改为独立页面 + +**涉及文件:** +- `frontend/ops_vue_js/src/views/warehouse/WarehouseItemEdit.vue` — 新建,物品编辑独立页面 +- `frontend/ops_vue_js/src/views/warehouse/WarehouseItemDetail.vue` — 编辑按钮改为 `router.push('/warehouse/item/edit/:id')`,移除弹窗代码 +- `frontend/ops_vue_js/src/router/index.js` — 新增 `/warehouse/item/edit/:id` 路由 + +**实现方式:** +- 创建 `WarehouseItemEdit.vue`,`onMounted` 获取物品数据(包含已有图片),通过 `setTimeout` 调用 `loadInitialFiles()` 加载到 dropzone +- 详情页编辑按钮改为跳转,移除弹窗及相关 state/function diff --git a/backend/my_work/routers/apiWarehouse.go b/backend/my_work/routers/apiWarehouse.go index 721a67e..1822188 100644 --- a/backend/my_work/routers/apiWarehouse.go +++ b/backend/my_work/routers/apiWarehouse.go @@ -3,6 +3,7 @@ package routers import ( "encoding/json" "ops/models" + "slices" "strconv" "time" @@ -82,6 +83,32 @@ type TabWarehouseItemWorkOrderBind struct { CreatedAt time.Time `gorm:"type:datetime;autoCreateTime"` } +var ( + warehouseUserGroup models.TabUserGroups_ + warehouseAdmins []uint +) + +// updateWarehouseAdminsCash 刷新仓库管理员缓存 +func updateWarehouseAdminsCash() { + warehouseAdmins = nil + warehouseAdmins = append(warehouseAdmins, 1) // id=1 超级管理员 + var binds []models.TabUserGroupBinds_ + models.DB.Where("group_id = ?", warehouseUserGroup.ID).Find(&binds) + for _, item := range binds { + if !slices.Contains(warehouseAdmins, item.UserID) { + warehouseAdmins = append(warehouseAdmins, item.UserID) + } + } +} + +// canModifyWarehouse 判断是否有权限修改/删除仓库资源(创建者或仓库管理员) +func canModifyWarehouse(userID, creatorUserID uint) bool { + if slices.Contains(warehouseAdmins, userID) { + return true + } + return userID == creatorUserID +} + // ---------- 初始化 ---------- func ApiWarehouseInit() { @@ -94,6 +121,14 @@ func ApiWarehouseInit() { &TabWarehouseLog{}, &TabWarehouseItemWorkOrderBind{}, ) + + warehouseUserGroup.Name = "warehouse_admin" + if models.DB.Where(&warehouseUserGroup).First(&warehouseUserGroup).Error == nil { + updateWarehouseAdminsCash() + } else { + warehouseUserGroup.Type = "usergroup" + models.DB.Create(&warehouseUserGroup) + } } // ---------- 路由注册 ---------- @@ -228,6 +263,11 @@ func ApiWarehouse(r *gin.RouterGroup) { return } + if !canModifyWarehouse(user.ID, c.CreatorID) { + ReturnJson(ctx, "no_permission", nil) + return + } + oldContent, _ := json.Marshal(c) models.DB.Model(&c).Updates(map[string]interface{}{ "title": from.Title, @@ -284,6 +324,11 @@ func ApiWarehouse(r *gin.RouterGroup) { return } + if !canModifyWarehouse(user.ID, c.CreatorID) { + ReturnJson(ctx, "no_permission", nil) + return + } + // 检查是否有子容器或物品 if c.ChildCount > 0 || c.ItemCount > 0 { ReturnJson(ctx, "container_not_empty", nil) @@ -314,7 +359,7 @@ func ApiWarehouse(r *gin.RouterGroup) { // 获取容器列表 r.POST("/list_container", func(ctx *gin.Context) { - isAuth, _, data := AuthenticationAuthority(ctx) + isAuth, user, data := AuthenticationAuthority(ctx) if !isAuth { ReturnJson(ctx, "userCookieError", nil) return @@ -357,15 +402,21 @@ func ApiWarehouse(r *gin.RouterGroup) { Limit(from.Entries). Find(&containers) + canModifyContainers := make([]bool, len(containers)) + for i, c := range containers { + canModifyContainers[i] = canModifyWarehouse(user.ID, c.CreatorID) + } + ReturnJson(ctx, "apiOK", gin.H{ - "all_count": count, - "containers": containers, + "all_count": count, + "containers": containers, + "canModifyContainers": canModifyContainers, }) }) // 获取容器详情 r.POST("/get_container", func(ctx *gin.Context) { - isAuth, _, data := AuthenticationAuthority(ctx) + isAuth, user, data := AuthenticationAuthority(ctx) if !isAuth { ReturnJson(ctx, "userCookieError", nil) return @@ -428,10 +479,11 @@ func ApiWarehouse(r *gin.RouterGroup) { } ReturnJson(ctx, "apiOK", gin.H{ - "container": c, - "photos": files, - "parent_chain": parentChain, - "depth": depth, + "container": c, + "photos": files, + "parent_chain": parentChain, + "depth": depth, + "canModifyContainer": canModifyWarehouse(user.ID, c.CreatorID), }) }) @@ -618,6 +670,11 @@ func ApiWarehouse(r *gin.RouterGroup) { return } + if !canModifyWarehouse(user.ID, item.CreatorID) { + ReturnJson(ctx, "no_permission", nil) + return + } + oldContent, _ := json.Marshal(item) models.DB.Model(&item).Updates(map[string]interface{}{ "name": from.Name, @@ -676,6 +733,11 @@ func ApiWarehouse(r *gin.RouterGroup) { return } + if !canModifyWarehouse(user.ID, item.CreatorID) { + ReturnJson(ctx, "no_permission", 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")) @@ -703,7 +765,7 @@ func ApiWarehouse(r *gin.RouterGroup) { // 获取物品列表 r.POST("/list_item", func(ctx *gin.Context) { - isAuth, _, data := AuthenticationAuthority(ctx) + isAuth, user, data := AuthenticationAuthority(ctx) if !isAuth { ReturnJson(ctx, "userCookieError", nil) return @@ -744,15 +806,21 @@ func ApiWarehouse(r *gin.RouterGroup) { Limit(from.Entries). Find(&items) + canModifyItems := make([]bool, len(items)) + for i, item := range items { + canModifyItems[i] = canModifyWarehouse(user.ID, item.CreatorID) + } + ReturnJson(ctx, "apiOK", gin.H{ - "all_count": count, - "items": items, + "all_count": count, + "items": items, + "canModifyItems": canModifyItems, }) }) // 获取物品详情 r.POST("/get_item", func(ctx *gin.Context) { - isAuth, _, data := AuthenticationAuthority(ctx) + isAuth, user, data := AuthenticationAuthority(ctx) if !isAuth { ReturnJson(ctx, "userCookieError", nil) return @@ -807,10 +875,11 @@ func ApiWarehouse(r *gin.RouterGroup) { } ReturnJson(ctx, "apiOK", gin.H{ - "item": item, - "photos": files, - "commits": commits, - "work_orders": workOrders, + "item": item, + "photos": files, + "commits": commits, + "work_orders": workOrders, + "canModifyItem": canModifyWarehouse(user.ID, item.CreatorID), }) }) diff --git a/frontend/ops_vue_js/src/components/useDropzone.vue b/frontend/ops_vue_js/src/components/useDropzone.vue index fa3bb5b..f19b523 100644 --- a/frontend/ops_vue_js/src/components/useDropzone.vue +++ b/frontend/ops_vue_js/src/components/useDropzone.vue @@ -226,11 +226,11 @@ function return_files() { function loadInitialFiles() { if (!dropzoneInstance || !prop.initialFiles?.length) return; + // 每次调用时先清空已有文件,避免重复追加 + files.splice(0, files.length) + prop.initialFiles.forEach((f) => { - // 构造 Dropzone 期望的 mock file 对象 - //console.log(f); - // 填充上传结果字段 - const url = `/api/files/get/${f.Sha256}`; + const url = `/api/files/get/${f.Sha256}` const mockFile = { name: f.Name, size: f.Size, @@ -238,17 +238,17 @@ function loadInitialFiles() { status: Dropzone.SUCCESS, accepted: true, upload: { uuid: f.Sha256 }, - isInput:true, + isInput: true, previewElement: null, _removeLink: null, - }; - // 通知 Dropzone "这是一个已存在的文件,不要上传" - // dropzoneInstance.emit("addedfile", mockFile); - // dropzoneInstance.emit("complete", mockFile); - // dropzoneInstance.files.push(mockFile); - // dropzoneInstance.emit("thumbnail", mockFile, url); + } - dropzoneInstance.displayExistingFile(mockFile, url); + // displayExistingFile 会触发 addedfile 事件,但我们需要手动 push 到 files 数组, + // 所以传 false 阻止事件触发,由我们自己 push,同时手动绑定 lightbox + dropzoneInstance.displayExistingFile(mockFile, url, false) + + // 为已有文件的预览元素绑定 lightbox 点击事件 + clik_file_event(mockFile) files.push({ uuid: f.Sha256, @@ -258,8 +258,8 @@ function loadInitialFiles() { file_name: f.Name, file_size: f.Size, is_upload: true, - }); - }); + }) + }) } // 组件挂载时初始化 @@ -277,6 +277,7 @@ onUnmounted(() => { defineExpose({ return_files, + loadInitialFiles, }); diff --git a/frontend/ops_vue_js/src/router/index.js b/frontend/ops_vue_js/src/router/index.js index 155eb3c..da2b57c 100644 --- a/frontend/ops_vue_js/src/router/index.js +++ b/frontend/ops_vue_js/src/router/index.js @@ -94,6 +94,11 @@ const router = createRouter({ name: 'warehouse-item-detail', component: () => import('@/views/warehouse/WarehouseItemDetail.vue'), }, + { + path: 'warehouse/item/edit/:id', + name: 'warehouse-item-edit', + component: () => import('@/views/warehouse/WarehouseItemEdit.vue'), + }, { path: 'admin', name: 'admin', diff --git a/frontend/ops_vue_js/src/views/warehouse/WarehouseContainerDetail.vue b/frontend/ops_vue_js/src/views/warehouse/WarehouseContainerDetail.vue index 77c2d96..d26b1e2 100644 --- a/frontend/ops_vue_js/src/views/warehouse/WarehouseContainerDetail.vue +++ b/frontend/ops_vue_js/src/views/warehouse/WarehouseContainerDetail.vue @@ -41,6 +41,7 @@ const container = ref(null) const photos = ref([]) const parentChain = ref([]) const containerDepth = ref(0) +const canModifyContainer = ref(false) const loadingDetail = ref(true) const notFound = ref(false) @@ -134,6 +135,7 @@ async function fetchContainer() { photos.value = data.photos ?? [] parentChain.value = data.parent_chain ?? [] containerDepth.value = data.depth ?? 0 + canModifyContainer.value = data.canModifyContainer === true } else { notFound.value = true } @@ -328,6 +330,7 @@ onMounted(async () => {
@@ -514,77 +478,6 @@ onMounted(() => { - - -
-
-

{{ t('warehouse.edit_item') }}

-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
-
-
-
+import { ref, reactive, onMounted } from 'vue' +import { useRoute, useRouter } from 'vue-router' +import { useI18n } from 'vue-i18n' +import { useToastStore } from '@/stores/toast' +import { usePageTitle } from '@/composables/usePageTitle' +import { warehouseApi } from '@/api/warehouse' +import useDropzone from '@/components/useDropzone.vue' + +usePageTitle('warehouse.edit_item') +const { t } = useI18n() +const route = useRoute() +const router = useRouter() +const toast = useToastStore() + +const itemId = ref(parseInt(route.params.id)) + +const form = reactive({ + name: '', + serial_number: '', + remark: '', + quantity: 1, +}) + +const submitting = ref(false) +const loadingItem = ref(true) +const itemNotFound = ref(false) +const containerName = ref('') +const existingPhotos = ref([]) + +const dropzoneRef = ref(null) + +function getPhotoHashes() { + return dropzoneRef.value?.return_files().map((f) => f.hash) ?? [] +} + +onMounted(async () => { + try { + const { errCode, data } = await warehouseApi.getItem(itemId.value) + if (errCode === 0 && data?.item) { + form.name = data.item.Name ?? '' + form.serial_number = data.item.SerialNumber ?? '' + form.remark = data.item.Remark ?? '' + form.quantity = data.item.Quantity ?? 1 + existingPhotos.value = data.photos ?? [] + + // 获取容器名称 + if (data.item.ContainerID) { + const { errCode: cErr, data: cData } = await warehouseApi.getContainer(data.item.ContainerID) + if (cErr === 0 && cData?.container) { + containerName.value = cData.container.Title + } + } + } else { + itemNotFound.value = true + } + } catch { + itemNotFound.value = true + } finally { + loadingItem.value = false + } +}) + +async function submit() { + if (!form.name.trim()) { + toast.error(t('warehouse.item_name_required')) + return + } + + // 等待图片上传完成 + await new Promise((r) => setTimeout(r, 200)) + const hashes = getPhotoHashes() + + submitting.value = true + try { + const { errCode } = await warehouseApi.updateItem({ + id: itemId.value, + name: form.name.trim(), + serial_number: form.serial_number.trim(), + remark: form.remark.trim(), + quantity: form.quantity > 0 ? form.quantity : 1, + photos: hashes, + }) + if (errCode === 0) { + toast.success(t('message.save_success')) + router.push(`/warehouse/item/${itemId.value}`) + } else { + toast.error(t('message.server_error')) + } + } catch { + toast.error(t('message.server_error')) + } finally { + submitting.value = false + } +} + + + diff --git a/frontend/ops_vue_js/src/views/warehouse/WarehouseItemList.vue b/frontend/ops_vue_js/src/views/warehouse/WarehouseItemList.vue index 605975c..7b9ba16 100644 --- a/frontend/ops_vue_js/src/views/warehouse/WarehouseItemList.vue +++ b/frontend/ops_vue_js/src/views/warehouse/WarehouseItemList.vue @@ -22,6 +22,7 @@ const toast = useToastStore() // ── 状态 ── const items = ref([]) +const canModifyItems = ref([]) // 并行数组:与 items 下标对应 const totalCount = ref(0) const pageSize = ref(10) const currentPage = ref(1) @@ -49,6 +50,11 @@ function getContainerTitle(cid) { return containerMap.value[cid] || `#${cid}` } +// ── 权限判断 ── +function canModifyItem(idx) { + return canModifyItems.value[idx] === true +} + // ── 获取容器名映射 ── async function fetchContainerMap() { try { @@ -95,6 +101,7 @@ async function fetchItems() { }) if (errCode === 0 && data) { items.value = data.items || [] + canModifyItems.value = data.canModifyItems || [] totalCount.value = data.all_count || 0 stats.total = data.all_count || 0 stats.inContainer = items.value.filter(i => i.container_id != null).length @@ -273,7 +280,7 @@ onMounted(() => { { {{ formatDate(item.created_at) }} - - + +
+ + {{ usersStore.getUsernameFromUserID(c.CreatorID) }}
@@ -554,7 +551,7 @@ onMounted(() => { {{ t('warehouse.quantity') }} {{ t('warehouse.location') }} {{ t('warehouse.created_at') }} - {{ t('warehouse.actions') }} + {{ t('warehouse.created_by') }} @@ -574,7 +571,7 @@ onMounted(() => { @@ -591,13 +588,14 @@ onMounted(() => { {{ formatDate(item.CreatedAt) }} - - + +
+ + {{ usersStore.getUsernameFromUserID(item.CreatorID) }} +
@@ -698,7 +696,6 @@ onMounted(() => { :message="t('warehouse.delete_confirm_msg', { name: deletingName })" :confirm-text="t('warehouse.delete')" :cancel-text="t('message.cancel')" - :confirm-loading="deleting" danger @confirm="doDeleteContainer" /> @@ -708,7 +705,6 @@ onMounted(() => { v-model="showDeleteItemConfirm" :title="t('warehouse.delete_item_title')" :message="t('warehouse.delete_item_msg', { name: deleteItemTarget?.name })" - :confirm-loading="deletingItem" @confirm="doDeleteItem" />