up功能基本完成
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user