up功能基本完成

This commit is contained in:
2026-04-23 22:52:55 +08:00
parent 78b70d4fec
commit 1b1ec7f64d
9 changed files with 467 additions and 228 deletions
+28 -32
View File
@@ -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`
- **弹窗用 `<Transition name="fade">` + `v-if` + `@click.self` 关闭,不用 `<dialog :open>`**`<dialog>``: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
+73 -4
View File
@@ -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,
"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
@@ -432,6 +483,7 @@ func ApiWarehouse(r *gin.RouterGroup) {
"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,
"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
@@ -811,6 +879,7 @@ func ApiWarehouse(r *gin.RouterGroup) {
"photos": files,
"commits": commits,
"work_orders": workOrders,
"canModifyItem": canModifyWarehouse(user.ID, item.CreatorID),
})
})
@@ -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,
@@ -241,14 +241,14 @@ function loadInitialFiles() {
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,
});
</script>
+5
View File
@@ -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',
@@ -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 () => {
</div>
<div class="flex gap-2">
<button
v-if="canModifyContainer"
class="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-dk-muted dark:bg-dk-base dark:text-white dark:hover:bg-dk-muted"
@click="openEdit"
>
@@ -335,7 +338,7 @@ onMounted(async () => {
{{ t('warehouse.edit') }}
</button>
<button
v-if="container.ChildCount === 0 && container.ItemCount === 0"
v-if="canModifyContainer && container.ChildCount === 0 && container.ItemCount === 0"
class="inline-flex items-center gap-1.5 rounded-lg border border-red-300 bg-white px-3 py-1.5 text-sm font-medium text-red-600 transition-colors hover:bg-red-50 dark:border-red-900 dark:bg-dk-base dark:text-red-400 dark:hover:bg-red-900/20"
@click="confirmDelete"
>
@@ -378,7 +381,14 @@ onMounted(async () => {
<!-- 元信息 -->
<div class="flex flex-wrap gap-x-6 gap-y-1 text-xs text-gray-400 dark:text-gray-500">
<span>{{ t('warehouse.created_by') }}: {{ usersStore.getUsernameFromUserID(container.CreatorID) }}</span>
<span class="flex items-center gap-1">
<span>{{ t('warehouse.created_by') }}:</span>
<img
:src="usersStore.getAvatarUrlFromUserID(container.CreatorID)"
class="w-4 h-4 rounded-full object-cover"
/>
{{ usersStore.getUsernameFromUserID(container.CreatorID) }}
</span>
<span>{{ t('warehouse.created_at') }}: {{ fmtTs(container.CreatedAt) }}</span>
<span>{{ t('warehouse.child_containers') }}: {{ container.ChildCount }}</span>
<span>{{ t('warehouse.items') }}: {{ container.ItemCount }}</span>
@@ -418,12 +428,13 @@ onMounted(async () => {
<th class="px-5 py-3 font-medium w-24 text-center">{{ t('warehouse.child_containers') }}</th>
<th class="px-5 py-3 font-medium w-24 text-center">{{ t('warehouse.items') }}</th>
<th class="px-5 py-3 font-medium whitespace-nowrap">{{ t('warehouse.created_at') }}</th>
<th class="px-5 py-3 font-medium">{{ t('warehouse.created_by') }}</th>
<th class="px-5 py-3 font-medium w-24 text-right">{{ t('warehouse.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="loadingSub">
<td colspan="5" class="px-5 py-8 text-center">
<td colspan="6" class="px-5 py-8 text-center">
<svg class="mx-auto h-5 w-5 animate-spin text-gray-400" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
@@ -431,7 +442,7 @@ onMounted(async () => {
</td>
</tr>
<tr v-else-if="subContainers.length === 0">
<td colspan="5" class="px-5 py-8 text-center text-gray-400 dark:text-gray-500">
<td colspan="6" class="px-5 py-8 text-center text-gray-400 dark:text-gray-500">
{{ t('warehouse.no_containers') }}
</td>
</tr>
@@ -459,6 +470,15 @@ onMounted(async () => {
</span>
</td>
<td class="px-5 py-3 text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap">{{ fmtTs(c.CreatedAt) }}</td>
<td class="px-5 py-3">
<div class="flex items-center gap-1.5">
<img
:src="usersStore.getAvatarUrlFromUserID(c.CreatorID)"
class="w-5 h-5 rounded-full object-cover flex-shrink-0"
/>
<span class="truncate text-gray-600 dark:text-gray-400">{{ usersStore.getUsernameFromUserID(c.CreatorID) }}</span>
</div>
</td>
<td class="px-5 py-3 text-right">
<button
class="text-xs text-blue-500 hover:underline"
@@ -534,12 +554,13 @@ onMounted(async () => {
<th class="px-5 py-3 font-medium">{{ t('warehouse.serial_number') }}</th>
<th class="px-5 py-3 font-medium w-20 text-center">{{ t('warehouse.quantity') }}</th>
<th class="px-5 py-3 font-medium whitespace-nowrap">{{ t('warehouse.created_at') }}</th>
<th class="px-5 py-3 font-medium">{{ t('warehouse.created_by') }}</th>
<th class="px-5 py-3 font-medium w-20 text-right">{{ t('warehouse.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="loadingItems">
<td colspan="5" class="px-5 py-8 text-center">
<td colspan="6" class="px-5 py-8 text-center">
<svg class="mx-auto h-5 w-5 animate-spin text-gray-400" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
@@ -547,7 +568,7 @@ onMounted(async () => {
</td>
</tr>
<tr v-else-if="items.length === 0">
<td colspan="5" class="px-5 py-8 text-center text-gray-400 dark:text-gray-500">
<td colspan="6" class="px-5 py-8 text-center text-gray-400 dark:text-gray-500">
{{ t('warehouse.no_items') }}
</td>
</tr>
@@ -567,6 +588,15 @@ onMounted(async () => {
<td class="px-5 py-3 text-xs text-gray-500 dark:text-gray-400 max-w-[140px] truncate">{{ item.serial_number || '—' }}</td>
<td class="px-5 py-3 text-center text-sm">{{ item.Quantity }}</td>
<td class="px-5 py-3 text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap">{{ fmtTs(item.CreatedAt) }}</td>
<td class="px-5 py-3">
<div class="flex items-center gap-1.5">
<img
:src="usersStore.getAvatarUrlFromUserID(item.CreatorID)"
class="w-5 h-5 rounded-full object-cover flex-shrink-0"
/>
<span class="truncate text-gray-600 dark:text-gray-400">{{ usersStore.getUsernameFromUserID(item.CreatorID) }}</span>
</div>
</td>
<td class="px-5 py-3 text-right">
<button
class="text-xs text-blue-500 hover:underline"
@@ -727,7 +757,6 @@ onMounted(async () => {
:confirm-text="t('warehouse.delete')"
:cancel-text="t('message.cancel')"
danger
:confirm-loading="deleting"
@confirm="doDelete"
/>
</template>
@@ -31,6 +31,7 @@ const item = ref(null)
const photos = ref([])
const commits = ref([])
const workOrders = ref([])
const canModifyItem = ref(false)
const loadingDetail = ref(true)
const notFound = ref(false)
@@ -40,16 +41,6 @@ const containerNames = reactive({})
// ── Tab ──
const activeTab = ref('work_orders')
// ── 编辑弹窗 ──
const showEdit = ref(false)
const editForm = reactive({
name: '',
serial_number: '',
remark: '',
quantity: 1,
})
const submittingEdit = ref(false)
// ── 移动弹窗 ──
const showMove = ref(false)
const moveTarget = ref(null)
@@ -118,6 +109,7 @@ async function fetchItem() {
photos.value = data.photos ?? []
commits.value = data.commits ?? []
workOrders.value = data.work_orders ?? []
canModifyItem.value = data.canModifyItem === true
loadContainerNames()
} else {
notFound.value = true
@@ -184,43 +176,6 @@ function openLinkWorkOrder() {
router.push('/work_order/add')
}
// ── 编辑 ──
function openEdit() {
editForm.name = item.value?.Name ?? ''
editForm.serial_number = item.value?.SerialNumber ?? ''
editForm.remark = item.value?.Remark ?? ''
editForm.quantity = item.value?.Quantity ?? 1
showEdit.value = true
}
async function submitEdit() {
if (!editForm.name.trim()) {
toast.error(t('warehouse.item_name_required'))
return
}
submittingEdit.value = true
try {
const { errCode } = await warehouseApi.updateItem({
id: itemId.value,
name: editForm.name.trim(),
serial_number: editForm.serial_number.trim(),
remark: editForm.remark.trim(),
quantity: editForm.quantity > 0 ? editForm.quantity : 1,
})
if (errCode === 0) {
toast.success(t('message.save_success'))
showEdit.value = false
fetchItem()
} else {
toast.error(t('message.server_error'))
}
} catch {
toast.error(t('message.server_error'))
} finally {
submittingEdit.value = false
}
}
// ── 移动 ──
async function openMove() {
moveTarget.value = item.value?.ContainerID ?? null
@@ -356,13 +311,15 @@ onMounted(() => {
{{ t('warehouse.move_item') }}
</button>
<button
v-if="canModifyItem"
class="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-dk-muted dark:bg-dk-base dark:text-white dark:hover:bg-dk-muted"
@click="openEdit"
@click="router.push(`/warehouse/item/edit/${itemId}`)"
>
<IconEdit :size="14" />
{{ t('warehouse.edit') }}
</button>
<button
v-if="canModifyItem"
class="inline-flex items-center gap-1.5 rounded-lg border border-red-300 bg-white px-3 py-1.5 text-sm font-medium text-red-600 transition-colors hover:bg-red-50 dark:border-red-900 dark:bg-dk-base dark:text-red-400 dark:hover:bg-red-900/20"
@click="showDeleteConfirm = true"
>
@@ -423,7 +380,14 @@ onMounted(() => {
<!-- 元信息 -->
<div class="flex flex-wrap gap-x-6 gap-y-1 text-xs text-gray-400 dark:text-gray-500">
<span>{{ t('warehouse.created_by') }}: {{ usersStore.getUsernameFromUserID(item.CreatorID) }}</span>
<span class="flex items-center gap-1">
<span>{{ t('warehouse.created_by') }}:</span>
<img
:src="usersStore.getAvatarUrlFromUserID(item.CreatorID)"
class="w-4 h-4 rounded-full object-cover"
/>
{{ usersStore.getUsernameFromUserID(item.CreatorID) }}
</span>
<span>{{ t('warehouse.created_at') }}: {{ fmtTs(item.CreatedAt) }}</span>
</div>
</div>
@@ -514,77 +478,6 @@ onMounted(() => {
</template>
</div>
<!-- 编辑弹窗 -->
<Transition name="fade">
<div
v-if="showEdit"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
@click.self="showEdit = false"
>
<div class="w-full max-w-md rounded-xl border border-gray-200 bg-white p-5 shadow-xl dark:border-dk-muted dark:bg-dk-card">
<h3 class="mb-4 text-base font-semibold text-gray-900 dark:text-white">{{ t('warehouse.edit_item') }}</h3>
<div class="space-y-3">
<div>
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('warehouse.item_name') }} *</label>
<input
v-model="editForm.name"
type="text"
:placeholder="t('warehouse.item_name_placeholder')"
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-dk-muted dark:bg-dk-base dark:text-white"
@keyup.enter="submitEdit"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('warehouse.serial_number') }}</label>
<input
v-model="editForm.serial_number"
type="text"
:placeholder="t('warehouse.serial_number_placeholder')"
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-dk-muted dark:bg-dk-base dark:text-white"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('warehouse.quantity') }}</label>
<input
v-model.number="editForm.quantity"
type="number"
min="1"
class="w-28 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-dk-muted dark:bg-dk-base dark:text-white"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('warehouse.remark') }}</label>
<textarea
v-model="editForm.remark"
:placeholder="t('warehouse.remark_placeholder')"
rows="3"
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-dk-muted dark:bg-dk-base dark:text-white"
></textarea>
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<button
class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm dark:border-dk-muted dark:bg-dk-base dark:text-white hover:bg-gray-50 dark:hover:bg-dk-muted"
@click="showEdit = false"
>
{{ t('message.cancel') }}
</button>
<button
class="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
:disabled="submittingEdit"
@click="submitEdit"
>
<svg v-if="submittingEdit" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
{{ t('message.save') }}
</button>
</div>
</div>
</div>
</Transition>
<!-- 移动弹窗 -->
<Transition name="fade">
<div
@@ -0,0 +1,243 @@
<script setup>
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
}
}
</script>
<template>
<div class="p-4 max-w-2xl mx-auto space-y-4">
<!-- 加载 -->
<div v-if="loadingItem" class="flex justify-center py-16">
<svg class="h-6 w-6 animate-spin text-gray-400" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
</div>
<!-- 未找到 -->
<div v-else-if="itemNotFound" class="flex flex-col items-center justify-center py-16 text-gray-400">
<p>{{ t('message.not_found') }}</p>
<button class="mt-2 text-sm text-blue-500 hover:underline" @click="router.push('/warehouse/container')">
{{ t('warehouse.back_to_list') }}
</button>
</div>
<!-- 表单 -->
<template v-else>
<!-- 面包屑 -->
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<RouterLink to="/warehouse/container" class="text-blue-500 hover:underline">
{{ t('warehouse.container_list') }}
</RouterLink>
<span>/</span>
<RouterLink
v-if="containerName"
:to="`/warehouse/container/${itemId}`"
class="text-blue-500 hover:underline"
>
{{ containerName }}
</RouterLink>
<span v-else>/</span>
<span>/</span>
<RouterLink
:to="`/warehouse/item/${itemId}`"
class="text-blue-500 hover:underline"
>
#{{ itemId }}
</RouterLink>
<span>/</span>
<span class="text-gray-700 dark:text-gray-200">{{ t('warehouse.edit_item') }}</span>
</div>
<!-- 表单卡片 -->
<div class="rounded-xl border border-gray-200 bg-white px-5 py-5 shadow dark:border-dk-muted dark:bg-dk-card">
<h2 class="mb-5 text-base font-semibold text-gray-900 dark:text-white">
{{ t('warehouse.edit_item') }}
</h2>
<div class="space-y-4">
<!-- 物品名称 -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('warehouse.item_name') }} *
</label>
<input
v-model="form.name"
type="text"
:placeholder="t('warehouse.item_name_placeholder')"
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-dk-muted dark:bg-dk-base dark:text-white"
@keyup.enter="submit"
/>
</div>
<!-- 序列号 -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('warehouse.serial_number') }}
</label>
<input
v-model="form.serial_number"
type="text"
:placeholder="t('warehouse.serial_number_placeholder')"
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-dk-muted dark:bg-dk-base dark:text-white"
/>
</div>
<!-- 数量 -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('warehouse.quantity') }}
</label>
<input
v-model.number="form.quantity"
type="number"
min="1"
class="w-28 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-dk-muted dark:bg-dk-base dark:text-white"
/>
</div>
<!-- 备注 -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('warehouse.remark') }}
</label>
<textarea
v-model="form.remark"
:placeholder="t('warehouse.remark_placeholder')"
rows="3"
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-dk-muted dark:bg-dk-base dark:text-white"
></textarea>
</div>
<!-- 图片上传 -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('purchase_addorder.upload_photos') }}
</label>
<useDropzone
ref="dropzoneRef"
uploadURL="/api/files/upload/image"
:max-files="9"
:initial-files="existingPhotos"
/>
</div>
</div>
<!-- 操作按钮 -->
<div class="mt-6 flex justify-end gap-2">
<button
class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm dark:border-dk-muted dark:bg-dk-base dark:text-white hover:bg-gray-50 dark:hover:bg-dk-muted"
@click="router.push(`/warehouse/item/${itemId}`)"
>
{{ t('message.cancel') }}
</button>
<button
class="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
:disabled="submitting"
@click="submit"
>
<svg v-if="submitting" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
{{ t('message.save') }}
</button>
</div>
</div>
</template>
</div>
</template>
@@ -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(() => {
</thead>
<tbody>
<tr
v-for="item in items"
v-for="(item, idx) in items"
:key="item.id"
class="border-b border-gray-100 cursor-pointer transition-colors hover:bg-gray-50 dark:border-dk-muted dark:hover:bg-dk-base"
@click="goToDetail(item)"
@@ -293,6 +300,7 @@ onMounted(() => {
<td class="px-5 py-3 text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap">{{ formatDate(item.created_at) }}</td>
<td class="px-5 py-3 text-right" @click.stop>
<button
v-if="canModifyItem(idx)"
class="inline-flex items-center justify-center w-7 h-7 rounded text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
@click="askDelete(item)"
>
@@ -347,7 +355,6 @@ onMounted(() => {
v-model="confirmDelete"
:title="t('warehouse.delete_item_title')"
:message="t('warehouse.delete_item_msg', { name: deleteTarget?.name })"
:confirm-loading="deletingItem"
@confirm="doDeleteItem"
/>
</template>
@@ -3,6 +3,7 @@ import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useToastStore } from '@/stores/toast'
import { useUsersStore } from '@/stores/users'
import { usePageTitle } from '@/composables/usePageTitle'
import { warehouseApi } from '@/api/warehouse'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
@@ -23,6 +24,7 @@ usePageTitle('warehouse.overview')
const { t, locale } = useI18n()
const router = useRouter()
const toast = useToastStore()
const usersStore = useUsersStore()
const isEn = computed(() => locale.value === 'en')
@@ -37,6 +39,7 @@ const stats = reactive({
// 容器相关
// ═══════════════════════════════════════════════════════
const containers = ref([])
const canModifyContainers = ref([]) // 并行数组:与 containers 下标对应
const containerTotal = ref(0)
const containerPage = ref(1)
const containerPageSize = ref(10)
@@ -68,6 +71,14 @@ function containerPageRange() {
return Array.from({ length: end - start + 1 }, (_, i) => start + i)
}
// ── 权限判断 ──
function canModifyContainer(index) {
return canModifyContainers.value[index] === true
}
function canModifyItem(index) {
return canModifyItems.value[index] === true
}
async function fetchContainerStats() {
try {
const { errCode, data } = await warehouseApi.getCount()
@@ -89,6 +100,7 @@ async function fetchContainers() {
})
if (errCode === 0) {
containers.value = data.containers ?? []
canModifyContainers.value = data.canModifyContainers ?? []
containerTotal.value = data.all_count ?? 0
} else {
toast.error(t('message.server_error'))
@@ -201,6 +213,7 @@ function jumpToContainer(id) {
// 物品相关
// ═══════════════════════════════════════════════════════
const items = ref([])
const canModifyItems = ref([]) // 并行数组:与 items 下标对应
const itemTotal = ref(0)
const itemPage = ref(1)
const itemPageSize = ref(10)
@@ -220,6 +233,7 @@ async function fetchItems() {
})
if (errCode === 0 && data) {
items.value = data.items || []
canModifyItems.value = data.canModifyItems || []
itemTotal.value = data.all_count || 0
itemStats.total = data.all_count || 0
itemStats.inContainer = items.value.filter(i => i.ContainerID != null).length
@@ -412,7 +426,7 @@ onMounted(() => {
<th class="px-6 py-3 font-medium w-24 text-center">{{ t('warehouse.child_containers') }}</th>
<th class="px-6 py-3 font-medium w-24 text-center">{{ t('warehouse.items') }}</th>
<th class="px-6 py-3 font-medium whitespace-nowrap w-44">{{ t('warehouse.created_at') }}</th>
<th class="px-6 py-3 font-medium w-28 text-right">{{ t('warehouse.actions') }}</th>
<th class="px-6 py-3 font-medium">{{ t('warehouse.created_by') }}</th>
</tr>
</thead>
<tbody>
@@ -432,7 +446,7 @@ onMounted(() => {
</tr>
<tr
v-else
v-for="c in containers"
v-for="(c, idx) in containers"
:key="c.ID"
class="cursor-pointer border-b border-gray-100 transition-colors hover:bg-gray-50 dark:border-dk-muted dark:hover:bg-dk-base"
@click="jumpToContainer(c.ID)"
@@ -458,30 +472,13 @@ onMounted(() => {
</span>
</td>
<td class="px-6 py-3 whitespace-nowrap text-gray-500 dark:text-gray-400">{{ fmtTs(c.CreatedAt) }}</td>
<td class="px-6 py-3 text-right" @click.stop>
<div class="flex items-center justify-end gap-1">
<button
v-if="c.ChildCount === 0 && c.ItemCount === 0"
class="rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-red-500 dark:hover:bg-dk-muted"
:title="t('warehouse.delete')"
@click="confirmDeleteContainer(c.ID, c.Title, $event)"
>
<IconTrash :size="15" />
</button>
<button
class="rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-blue-500 dark:hover:bg-dk-muted"
:title="t('warehouse.edit')"
@click="openEditContainer(c.ID, $event)"
>
<IconEdit :size="15" />
</button>
<button
class="rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-blue-500 dark:hover:bg-dk-muted"
:title="t('warehouse.view_items')"
@click="jumpToContainer(c.ID)"
>
<IconChevronRight :size="15" />
</button>
<td class="px-6 py-3">
<div class="flex items-center gap-1.5">
<img
:src="usersStore.getAvatarUrlFromUserID(c.CreatorID)"
class="w-5 h-5 rounded-full object-cover flex-shrink-0"
/>
<span class="truncate text-gray-600 dark:text-gray-400">{{ usersStore.getUsernameFromUserID(c.CreatorID) }}</span>
</div>
</td>
</tr>
@@ -554,7 +551,7 @@ onMounted(() => {
<th class="px-6 py-3 font-medium w-20 text-center">{{ t('warehouse.quantity') }}</th>
<th class="px-6 py-3 font-medium">{{ t('warehouse.location') }}</th>
<th class="px-6 py-3 font-medium whitespace-nowrap">{{ t('warehouse.created_at') }}</th>
<th class="px-6 py-3 font-medium w-16 text-right">{{ t('warehouse.actions') }}</th>
<th class="px-6 py-3 font-medium">{{ t('warehouse.created_by') }}</th>
</tr>
</thead>
<tbody>
@@ -574,7 +571,7 @@ onMounted(() => {
</tr>
<tr
v-else
v-for="item in items" :key="item.ID"
v-for="(item, idx) in items" :key="item.ID"
class="cursor-pointer border-b border-gray-100 transition-colors hover:bg-gray-50 dark:border-dk-muted dark:hover:bg-dk-base"
@click="goToItemDetail(item)"
>
@@ -591,13 +588,14 @@ onMounted(() => {
</span>
</td>
<td class="px-6 py-3 whitespace-nowrap text-xs text-gray-400 dark:text-gray-500">{{ formatDate(item.CreatedAt) }}</td>
<td class="px-6 py-3 text-right" @click.stop>
<button
class="flex h-7 w-7 items-center justify-center rounded text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
@click="askDeleteItem(item)"
>
<IconTrash :size="14" />
</button>
<td class="px-6 py-3">
<div class="flex items-center gap-1.5">
<img
:src="usersStore.getAvatarUrlFromUserID(item.CreatorID)"
class="w-5 h-5 rounded-full object-cover flex-shrink-0"
/>
<span class="truncate text-gray-600 dark:text-gray-400">{{ usersStore.getUsernameFromUserID(item.CreatorID) }}</span>
</div>
</td>
</tr>
</tbody>
@@ -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"
/>
</template>