Files
ops2/frontend/ops_vue_js/src/views/warehouse/WarehouseOverview.vue
T
2026-04-29 20:09:30 +08:00

725 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter, RouterLink } 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'
import {
IconPlus,
IconChevronLeft,
IconChevronRight,
IconFolder,
IconFolders,
IconPackage,
IconSearch,
IconTrash,
IconEdit,
IconArrowRight,
IconTool,
IconUser,
} from '@tabler/icons-vue'
usePageTitle('warehouse.overview')
const { t, locale } = useI18n()
const router = useRouter()
const toast = useToastStore()
const usersStore = useUsersStore()
const isEn = computed(() => locale.value === 'en')
// ── 统计 ──
const stats = reactive({
container_total: 0,
item_total: 0,
unstored_items: 0,
})
// ═══════════════════════════════════════════════════════
// 容器相关
// ═══════════════════════════════════════════════════════
const containers = ref([])
const canModifyContainers = ref([]) // 并行数组:与 containers 下标对应
const containerTotal = ref(0)
const containerPage = ref(1)
const containerPageSize = ref(10)
const containerSearch = ref('')
const containerLoading = ref(false)
// 新增/编辑弹窗
const showContainerForm = ref(false)
const containerFormTitle = ref('')
const editingContainerId = ref(null)
const containerForm = reactive({ title: '', remark: '' })
const submittingContainer = ref(false)
// 删除确认
const showDeleteConfirm = ref(false)
const deletingId = ref(null)
const deletingName = ref('')
const deleting = ref(false)
const containerTotalPages = computed(() => Math.ceil(containerTotal.value / containerPageSize.value) || 1)
function containerPageRange() {
const total = containerTotalPages.value
const cur = containerPage.value
let start = Math.max(1, cur - 2)
let end = Math.min(cur + 4, total)
if (end - start < 4) start = Math.max(1, end - 4)
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()
if (errCode === 0) {
stats.container_total = data.container_total ?? 0
stats.item_total = data.item_total ?? 0
stats.unstored_items = data.unstored_items ?? 0
}
} catch { /* silent */ }
}
async function fetchContainers() {
containerLoading.value = true
try {
const { errCode, data } = await warehouseApi.getContainers({
search: containerSearch.value,
entries: containerPageSize.value,
page: containerPage.value,
})
if (errCode === 0) {
containers.value = data.containers ?? []
canModifyContainers.value = data.canModifyContainers ?? []
containerTotal.value = data.all_count ?? 0
} else {
toast.error(t('message.server_error'))
}
} catch { /* interceptor handled */ }
finally { containerLoading.value = false }
}
function goContainerPage(page) {
if (page < 1 || page > containerTotalPages.value) return
containerPage.value = page
fetchContainers()
}
function handleContainerPageSize(e) {
let val = parseInt(e.target.value) || 10
if (val > 300) val = 300
if (val < 1) val = 1
containerPageSize.value = val
containerPage.value = 1
fetchContainers()
}
function openAddContainer() {
containerFormTitle.value = t('warehouse.add_container')
editingContainerId.value = null
containerForm.title = ''
containerForm.remark = ''
showContainerForm.value = true
}
async function openEditContainer(id, e) {
e.stopPropagation()
try {
const { errCode, data } = await warehouseApi.getContainer(id)
if (errCode === 0) {
containerFormTitle.value = t('warehouse.edit_container')
editingContainerId.value = id
containerForm.title = data.container.title ?? ''
containerForm.remark = data.container.remark ?? ''
showContainerForm.value = true
} else {
toast.error(t('message.server_error'))
}
} catch { /* interceptor handled */ }
}
async function submitContainerForm() {
if (!containerForm.title.trim()) {
toast.warning(t('warehouse.title_required'))
return
}
submittingContainer.value = true
try {
const payload = { title: containerForm.title.trim(), remark: containerForm.remark.trim() }
const { errCode } = editingContainerId.value
? await warehouseApi.updateContainer({ id: editingContainerId.value, ...payload })
: await warehouseApi.addContainer(payload)
if (errCode === 0) {
showContainerForm.value = false
toast.success(t('message.save_success'))
fetchContainers()
fetchContainerStats()
} else {
toast.error(t('message.server_error'))
}
} catch { /* interceptor handled */ }
finally { submittingContainer.value = false }
}
function confirmDeleteContainer(id, title, e) {
e.stopPropagation()
deletingId.value = id
deletingName.value = title
showDeleteConfirm.value = true
}
async function doDeleteContainer() {
deleting.value = true
try {
const { errCode } = await warehouseApi.deleteContainer(deletingId.value)
if (errCode === 0) {
toast.success(t('message.delete_ok'))
showDeleteConfirm.value = false
fetchContainers()
fetchContainerStats()
} else {
toast.error(t('message.server_error'))
}
} catch { /* silent */ }
finally { deleting.value = false }
}
function jumpToContainer(id) {
router.push(`/warehouse/container/${id}`)
}
// ═══════════════════════════════════════════════════════
// 物品相关
// ═══════════════════════════════════════════════════════
const items = ref([])
const canModifyItems = ref([]) // 并行数组:与 items 下标对应
const itemTotal = ref(0)
const itemPage = ref(1)
const itemPageSize = ref(10)
const itemSearch = ref('')
const itemLoading = ref(false)
const itemTotalPages = computed(() => Math.ceil(itemTotal.value / itemPageSize.value) || 1)
const itemStats = reactive({ total: 0, inContainer: 0, unstored: 0 })
async function fetchItems() {
itemLoading.value = true
try {
const { errCode, data } = await warehouseApi.getItems({
search: itemSearch.value.trim(),
entries: itemPageSize.value,
page: itemPage.value,
})
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
itemStats.unstored = items.value.filter(i => i.ContainerID == null).length
}
} catch { toast.error(t('message.server_error')) }
finally { itemLoading.value = false }
}
let searchTimer = null
function onItemSearchInput() {
clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
itemPage.value = 1
fetchItems()
}, 400)
}
function prevItemPage() {
if (itemPage.value > 1) { itemPage.value--; fetchItems() }
}
function nextItemPage() {
if (itemPage.value < itemTotalPages.value) { itemPage.value++; fetchItems() }
}
function goItemPage(p) {
if (p >= 1 && p <= itemTotalPages.value && p !== itemPage.value) { itemPage.value = p; fetchItems() }
}
const itemPageNumbers = computed(() => {
const total = itemTotalPages.value
const cur = itemPage.value
if (total <= 5) return Array.from({ length: total }, (_, i) => i + 1)
const pages = []
if (cur <= 3) pages.push(1, 2, 3, 4, '...', total)
else if (cur >= total - 2) pages.push(1, '...', total - 3, total - 2, total - 1, total)
else pages.push(1, '...', cur - 1, cur, cur + 1, '...', total)
return pages
})
function goToItemDetail(item) {
router.push(`/warehouse/item/${item.ID}`)
}
const deleteItemTarget = ref(null)
const showDeleteItemConfirm = ref(false)
const deletingItem = ref(false)
function askDeleteItem(item) {
deleteItemTarget.value = item
showDeleteItemConfirm.value = true
}
async function doDeleteItem() {
if (!deleteItemTarget.value) return
deletingItem.value = true
try {
const { errCode } = await warehouseApi.deleteItem(deleteItemTarget.value.ID)
if (errCode === 0) {
toast.success(t('message.delete_success'))
showDeleteItemConfirm.value = false
deleteItemTarget.value = null
fetchItems()
fetchContainerStats()
} else {
toast.error(t('message.server_error'))
}
} catch { toast.error(t('message.server_error')) }
finally { deletingItem.value = false }
}
// ── 工具函数 ──
function formatDate(dateStr) {
if (!dateStr) return '—'
try {
let d
if (typeof dateStr === 'string' && /^\d+$/.test(dateStr)) {
// Unix timestamp string like "1712345678"
d = new Date(parseInt(dateStr, 10) * 1000)
} else {
d = new Date(dateStr)
}
if (isNaN(d.getTime())) return '—'
return d.toLocaleString(isEn.value ? 'en-US' : 'zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })
} catch { return dateStr }
}
function fmtTs(ts) {
if (!ts) return '—'
let d
if (typeof ts === 'number') {
// Unix timestamp in seconds (number)
d = new Date(ts * 1000)
} else if (typeof ts === 'string') {
// Check if it's a Unix timestamp string like "1712345678"
if (/^\d+$/.test(ts)) {
d = new Date(parseInt(ts, 10) * 1000)
} else {
// ISO 8601 or other string format
d = new Date(ts)
}
} else {
d = new Date(ts)
}
if (isNaN(d.getTime())) return '—'
return d.toLocaleString()
}
// ── 初始化 ──
onMounted(() => {
fetchContainerStats()
fetchContainers()
fetchItems()
})
</script>
<template>
<div class="mx-auto max-w-7xl px-6 py-6 space-y-4">
<!-- 统计卡片 -->
<div class="grid grid-cols-3 gap-4">
<div class="rounded-xl border border-gray-200 bg-white px-5 py-4 shadow dark:border-dk-muted dark:bg-dk-card">
<div class="flex items-center gap-2 text-gray-500 dark:text-gray-400">
<IconFolders :size="18" />
<span class="text-sm">{{ t('warehouse.container_count') }}</span>
</div>
<div class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">{{ stats.container_total }}</div>
</div>
<div class="rounded-xl border border-gray-200 bg-white px-5 py-4 shadow dark:border-dk-muted dark:bg-dk-card">
<div class="flex items-center gap-2 text-gray-500 dark:text-gray-400">
<IconPackage :size="18" />
<span class="text-sm">{{ t('warehouse.item_count') }}</span>
</div>
<div class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">{{ stats.item_total }}</div>
</div>
<div class="rounded-xl border border-gray-200 bg-white px-5 py-4 shadow dark:border-dk-muted dark:bg-dk-card">
<div class="flex items-center gap-2 text-gray-500 dark:text-gray-400">
<IconPackage :size="18" />
<span class="text-sm">{{ t('warehouse.unstored_items') }}</span>
</div>
<div class="mt-1 text-2xl font-bold text-orange-600 dark:text-orange-400">{{ stats.unstored_items }}</div>
</div>
</div>
<!-- 主卡片容器 -->
<div class="rounded-xl border border-gray-200 bg-white shadow dark:border-dk-muted dark:bg-dk-card">
<!-- Header -->
<div class="flex items-center justify-between border-b border-gray-100 px-6 py-4 dark:border-dk-muted">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('warehouse.container_list') }}</h3>
<button
@click="openAddContainer"
class="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-700"
>
<IconPlus :size="16" />
{{ t('warehouse.add_container') }}
</button>
</div>
<!-- 搜索栏 -->
<div class="flex items-center gap-3 border-b border-gray-100 px-6 py-3 dark:border-dk-muted">
<div class="relative flex-1 max-w-xs">
<IconSearch class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" :size="16" />
<input
v-model="containerSearch"
type="text"
:placeholder="t('warehouse.search_placeholder')"
class="w-full rounded-lg border border-gray-300 bg-white py-1.5 pl-9 pr-3 text-sm dark:border-dk-muted dark:bg-dk-base dark:text-white"
@keyup.enter="containerPage = 1; fetchContainers()"
/>
</div>
<button
@click="containerPage = 1; fetchContainers()"
class="rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm dark:border-dk-muted dark:bg-dk-base dark:text-white hover:bg-gray-50 dark:hover:bg-dk-muted"
>
{{ t('purchase.search') }}
</button>
</div>
<!-- 表格 -->
<div class="overflow-x-auto">
<table class="w-full text-left text-sm text-gray-900">
<thead>
<tr class="border-b border-gray-200 bg-gray-50 text-gray-500 dark:border-dk-muted dark:bg-dk-base dark:text-gray-400">
<th class="px-6 py-3 font-medium w-16">ID</th>
<th class="px-6 py-3 font-medium">{{ t('warehouse.container_name') }}</th>
<th class="px-6 py-3 font-medium">{{ t('warehouse.remark') }}</th>
<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 whitespace-nowrap w-44">{{ t('warehouse.updated_at') }}</th>
<th class="px-6 py-3 font-medium">{{ t('warehouse.created_by') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="containerLoading">
<td colspan="8" class="px-6 py-8 text-center text-gray-400">
<svg class="mx-auto mb-2 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" />
</svg>
{{ t('message.loading') }}
</td>
</tr>
<tr v-else-if="containers.length === 0">
<td colspan="8" class="px-6 py-8 text-center text-gray-400 dark:text-gray-500">
{{ t('warehouse.no_containers') }}
</td>
</tr>
<tr
v-else
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)"
>
<td class="px-6 py-3 text-gray-500 dark:text-gray-400">{{ c.ID }}</td>
<td class="px-6 py-3">
<div class="flex items-center gap-2">
<IconFolder class="flex-shrink-0 text-blue-500" :size="18" />
<span class="max-w-xs truncate font-medium text-gray-900 dark:text-white">{{ c.Title }}</span>
</div>
</td>
<td class="px-6 py-3 max-w-xs truncate text-gray-500 dark:text-gray-400">{{ c.Remark || '—' }}</td>
<td class="px-6 py-3 text-center">
<span class="inline-flex items-center gap-1 rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-400">
<IconFolders :size="12" />
{{ c.ChildCount }}
</span>
</td>
<td class="px-6 py-3 text-center">
<span class="inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-900/40 dark:text-green-400">
<IconPackage :size="12" />
{{ c.ItemCount }}
</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 whitespace-nowrap text-gray-500 dark:text-gray-400">{{ fmtTs(c.UpdatedAt) }}</td>
<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>
</tbody>
</table>
</div>
<!-- 分页 -->
<div class="flex flex-col items-center gap-3 border-t border-gray-100 px-6 py-4 sm:flex-row sm:justify-between dark:border-dk-muted">
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<span> {{ containerTotal }} </span>
<span>每页</span>
<input
type="number"
:value="containerPageSize"
min="1"
max="300"
class="w-14 rounded border border-gray-300 px-1.5 py-0.5 text-center text-sm dark:border-dk-muted dark:bg-dk-base dark:text-white"
@change="handleContainerPageSize"
/>
<span></span>
</div>
<div class="flex items-center gap-1">
<button @click="goContainerPage(1)" :disabled="containerPage === 1" class="rounded p-1.5 text-gray-500 hover:bg-gray-100 disabled:opacity-30 dark:hover:bg-dk-muted">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 17l-5-5 5-5M18 17l-5-5 5-5"/></svg>
</button>
<button @click="goContainerPage(containerPage - 1)" :disabled="containerPage === 1" class="rounded p-1.5 text-gray-500 hover:bg-gray-100 disabled:opacity-30 dark:hover:bg-dk-muted">
<IconChevronLeft :size="16" />
</button>
<button
v-for="p in containerPageRange()" :key="p"
@click="goContainerPage(p)"
:class="['rounded px-2.5 py-1 text-sm', p === containerPage ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-dk-muted']"
>{{ p }}</button>
<button @click="goContainerPage(containerPage + 1)" :disabled="containerPage === containerTotalPages" class="rounded p-1.5 text-gray-500 hover:bg-gray-100 disabled:opacity-30 dark:hover:bg-dk-muted">
<IconChevronRight :size="16" />
</button>
<button @click="goContainerPage(containerTotalPages)" :disabled="containerPage === containerTotalPages" class="rounded p-1.5 text-gray-500 hover:bg-gray-100 disabled:opacity-30 dark:hover:bg-dk-muted">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 17l5-5-5-5M6 17l5-5-5-5"/></svg>
</button>
</div>
</div>
</div>
<!-- 主卡片物品 -->
<div class="rounded-xl border border-gray-200 bg-white shadow dark:border-dk-muted dark:bg-dk-card">
<!-- Header -->
<div class="flex items-center justify-between border-b border-gray-100 px-6 py-4 dark:border-dk-muted">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('warehouse.item_list') }}</h3>
</div>
<!-- 搜索栏 -->
<div class="flex items-center gap-3 border-b border-gray-100 px-6 py-3 dark:border-dk-muted">
<IconSearch class="flex-shrink-0 text-gray-400" :size="18" />
<input
v-model="itemSearch"
class="flex-1 bg-transparent text-sm text-gray-900 outline-none dark:text-white"
:placeholder="t('warehouse.search_item_placeholder')"
@input="onItemSearchInput"
/>
</div>
<!-- 表格 -->
<div class="overflow-x-auto">
<table class="w-full text-left text-sm text-gray-900 dark:text-white">
<thead>
<tr class="border-b border-gray-200 bg-gray-50 text-gray-500 dark:border-dk-muted dark:bg-dk-base dark:text-gray-400">
<th class="px-5 py-3 font-medium w-50">{{ t('warehouse.item_name') }}</th>
<th class="px-5 py-3 font-medium w-50">{{ t('warehouse.serial_number') }}</th>
<th class="px-5 py-3 font-medium w-50">{{ t('warehouse.remark') }}</th>
<th class="px-5 py-3 font-medium text-center">{{ t('warehouse.quantity') }}</th>
<th class="px-5 py-3 font-medium text-center">{{ t('work_order.work_order_count') }}</th>
<th class="px-5 py-3 font-medium ">{{ t('customer.related_customers') }}</th>
<th class="px-5 py-3 font-medium whitespace-nowrap">{{ t('warehouse.updated_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="itemLoading">
<td colspan="9" class="px-6 py-8 text-center text-gray-400">
<svg class="mx-auto mb-2 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" />
</svg>
{{ t('message.loading') }}
</td>
</tr>
<tr v-else-if="items.length === 0">
<td colspan="9" class="px-6 py-8 text-center text-gray-400 dark:text-gray-500">
{{ t('warehouse.no_items') }}
</td>
</tr>
<tr
v-else
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)"
>
<td class="px-5 py-3 font-medium max-w-[10rem] truncate">{{ item.Name }}</td>
<td class="px-5 py-3 text-xs text-gray-500 dark:text-gray-400 max-w-[140px] truncate">{{ item.SerialNumber || '—' }}</td>
<td class="px-5 py-3 text-xs text-gray-500 dark:text-gray-400 max-w-[200px] truncate">{{ item.Remark || '—' }}</td>
<td class="px-5 py-3 text-center text-sm">{{ item.Quantity }}</td>
<td class="px-5 py-3 text-center">
<span v-if="item.WorkOrderCount > 0" class="inline-flex items-center gap-1 rounded-full bg-orange-100 px-2 py-0.5 text-xs font-medium text-orange-700 dark:bg-orange-900/40 dark:text-orange-400">
<IconTool :size="12" />
{{ item.WorkOrderCount }}
</span>
<span v-else class="text-gray-400"></span>
</td>
<td class="px-5 py-3">
<div v-if="item.Customers && item.Customers.length > 0" class="flex flex-wrap gap-1">
<RouterLink
v-for="customer in item.Customers.slice(0, 3)"
:key="customer.id"
:to="`/customer/detail/${customer.id}`"
class="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 hover:bg-blue-200 dark:bg-blue-900/40 dark:text-blue-400 dark:hover:bg-blue-900/60 whitespace-nowrap"
@click.stop
>
<IconUser :size="10" />
{{ customer.first_name }} {{ customer.last_name }}
</RouterLink>
<span v-if="item.Customers.length > 3" class="text-xs text-gray-400">+{{ item.Customers.length - 3 }}</span>
</div>
<span v-else class="text-gray-400"></span>
</td>
<td class="px-5 py-3 text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap">{{ formatDate(item.UpdatedAt) }}</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"
@click.stop="goToItemDetail(item)"
>
{{ t('warehouse.view_items') }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div v-if="itemTotalPages > 1" class="flex items-center justify-between border-t border-gray-100 px-6 py-3 dark:border-dk-muted">
<div class="text-xs text-gray-400 dark:text-gray-500">
{{ t('warehouse.total_items', { count: itemTotal }) }}
</div>
<div class="flex items-center gap-1">
<button
class="flex h-7 w-7 items-center justify-center rounded text-sm text-gray-500 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-30 dark:text-gray-400 dark:hover:bg-dk-muted"
:disabled="itemPage === 1"
@click="prevItemPage"
>
<IconChevronLeft :size="15" />
</button>
<template v-for="p in itemPageNumbers" :key="p">
<span v-if="p === '...'" class="px-1 text-sm text-gray-400"></span>
<button
v-else
class="flex h-7 w-7 items-center justify-center rounded text-sm"
:class="p === itemPage
? 'bg-blue-600 font-medium text-white'
: 'text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-dk-muted'"
@click="goItemPage(p)"
>{{ p }}</button>
</template>
<button
class="flex h-7 w-7 items-center justify-center rounded text-sm text-gray-500 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-30 dark:text-gray-400 dark:hover:bg-dk-muted"
:disabled="itemPage === itemTotalPages"
@click="nextItemPage"
>
<IconChevronRight :size="15" />
</button>
</div>
</div>
</div>
</div>
<!-- 容器 新增/编辑弹窗 -->
<Transition name="fade">
<div
v-if="showContainerForm"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
@click.self="showContainerForm = 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">{{ containerFormTitle }}</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.container_name') }} <span class="text-red-500">*</span>
</label>
<input
v-model="containerForm.title"
type="text"
:placeholder="t('warehouse.title_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="submitContainerForm"
/>
</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="containerForm.remark"
rows="3"
:placeholder="t('warehouse.remark_placeholder')"
class="w-full resize-none 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
@click="showContainerForm = false"
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"
>
{{ t('message.cancel') }}
</button>
<button
@click="submitContainerForm"
:disabled="submittingContainer"
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-60"
>
{{ submittingContainer ? t('message.submitting') : t('message.submit') }}
</button>
</div>
</div>
</div>
</Transition>
<!-- 容器 删除确认 -->
<ConfirmDialog
v-model="showDeleteConfirm"
:title="t('warehouse.delete_confirm_title')"
:message="t('warehouse.delete_confirm_msg', { name: deletingName })"
:confirm-text="t('warehouse.delete')"
:cancel-text="t('message.cancel')"
danger
@confirm="doDeleteContainer"
/>
<!-- 物品 删除确认 -->
<ConfirmDialog
v-model="showDeleteItemConfirm"
:title="t('warehouse.delete_item_title')"
:message="t('warehouse.delete_item_msg', { name: deleteItemTarget?.name })"
@confirm="doDeleteItem"
/>
</template>