1223 lines
34 KiB
Vue
1223 lines
34 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref, watch } from 'vue'
|
||
|
||
import AdminPaginationBar from '@/components/admin/AdminPaginationBar.vue'
|
||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||
import { showConfirm, showError, showSuccess } from '@/lib/feedback'
|
||
import {
|
||
createAdminInventoryItem,
|
||
fetchAdminInventoryItems,
|
||
fetchAdminInventorySkuSuggestions,
|
||
importAdminInventoryItems,
|
||
invalidateAdminInventoryItem,
|
||
releaseAdminInventoryItem,
|
||
} from '@/services/admin'
|
||
import type { AdminInventoryItemListItem, AdminInventorySkuSuggestion, AdminPagination } from '@/types/admin'
|
||
import { hasAdminRole } from '@/utils/admin-auth'
|
||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||
import {
|
||
adminInventoryCredentialTypeOptions,
|
||
adminInventoryStatusOptions,
|
||
} from '@/utils/admin-options'
|
||
|
||
type InventoryImportRow = {
|
||
skuCode: string
|
||
credentialType: string
|
||
batchNo: string
|
||
inventoryGroupCode: string
|
||
displayValue: string
|
||
}
|
||
|
||
type InventoryPoolGroup = {
|
||
key: string
|
||
skuCode: string
|
||
credentialType: string
|
||
inventoryGroupCode: string
|
||
totalCount: number
|
||
availableCount: number
|
||
reservedCount: number
|
||
deliveredCount: number
|
||
invalidCount: number
|
||
latestUpdatedAt: string
|
||
batchSummary: string
|
||
}
|
||
|
||
const loading = ref(true)
|
||
const importing = ref(false)
|
||
const creating = ref(false)
|
||
const actionLoadingId = ref<number | null>(null)
|
||
const errorMessage = ref('')
|
||
|
||
const filterSkuCode = ref('')
|
||
const filterCredentialType = ref('')
|
||
const filterStatus = ref('')
|
||
const filterBatchNo = ref('')
|
||
const filterInventoryGroupCode = ref('')
|
||
|
||
const entrySkuCode = ref('')
|
||
const entryCredentialType = ref('tencent_code')
|
||
const entryBatchNo = ref('')
|
||
const entryInventoryGroupCode = ref('')
|
||
const singleDisplayValue = ref('')
|
||
const bulkText = ref('')
|
||
const skuSuggestionsLoading = ref(false)
|
||
const inventorySkuSuggestions = ref<AdminInventorySkuSuggestion[]>([])
|
||
|
||
const items = ref<AdminInventoryItemListItem[]>([])
|
||
const pagination = ref<AdminPagination>({
|
||
page: 1,
|
||
pageSize: 20,
|
||
total: 0,
|
||
})
|
||
|
||
const pageSummary = computed(() => {
|
||
const availableCount = items.value.filter((item) => item.status === 'available').length
|
||
const reservedCount = items.value.filter((item) => item.status === 'reserved').length
|
||
const deliveredCount = items.value.filter((item) => item.status === 'consumed').length
|
||
const invalidCount = items.value.filter((item) => item.status === 'invalid').length
|
||
|
||
return {
|
||
availableCount,
|
||
reservedCount,
|
||
deliveredCount,
|
||
invalidCount,
|
||
}
|
||
})
|
||
|
||
const inventoryGroups = computed<InventoryPoolGroup[]>(() => {
|
||
const groupMap = new Map<string, {
|
||
skuCode: string
|
||
credentialType: string
|
||
inventoryGroupCode: string
|
||
totalCount: number
|
||
availableCount: number
|
||
reservedCount: number
|
||
deliveredCount: number
|
||
invalidCount: number
|
||
latestUpdatedAt: string
|
||
batches: Set<string>
|
||
}>()
|
||
|
||
for (const item of items.value) {
|
||
const key = `${item.skuCode}::${item.credentialType || 'tencent_code'}::${item.inventoryGroupCode || ''}`
|
||
const current = groupMap.get(key) || {
|
||
skuCode: item.skuCode,
|
||
credentialType: item.credentialType || 'tencent_code',
|
||
inventoryGroupCode: item.inventoryGroupCode || '',
|
||
totalCount: 0,
|
||
availableCount: 0,
|
||
reservedCount: 0,
|
||
deliveredCount: 0,
|
||
invalidCount: 0,
|
||
latestUpdatedAt: item.updatedAt,
|
||
batches: new Set<string>(),
|
||
}
|
||
|
||
current.totalCount += 1
|
||
|
||
if (item.status === 'available') {
|
||
current.availableCount += 1
|
||
} else if (item.status === 'reserved') {
|
||
current.reservedCount += 1
|
||
} else if (item.status === 'consumed') {
|
||
current.deliveredCount += 1
|
||
} else if (item.status === 'invalid') {
|
||
current.invalidCount += 1
|
||
}
|
||
|
||
if (item.batchNo) {
|
||
current.batches.add(item.batchNo)
|
||
}
|
||
|
||
if (String(item.updatedAt || '') > String(current.latestUpdatedAt || '')) {
|
||
current.latestUpdatedAt = item.updatedAt
|
||
}
|
||
|
||
groupMap.set(key, current)
|
||
}
|
||
|
||
return Array.from(groupMap.entries())
|
||
.map(([key, value]) => ({
|
||
key,
|
||
skuCode: value.skuCode,
|
||
credentialType: value.credentialType,
|
||
inventoryGroupCode: value.inventoryGroupCode,
|
||
totalCount: value.totalCount,
|
||
availableCount: value.availableCount,
|
||
reservedCount: value.reservedCount,
|
||
deliveredCount: value.deliveredCount,
|
||
invalidCount: value.invalidCount,
|
||
latestUpdatedAt: value.latestUpdatedAt,
|
||
batchSummary: value.batches.size > 0 ? Array.from(value.batches).slice(0, 3).join(' / ') : '未分批',
|
||
}))
|
||
.sort((left, right) => {
|
||
if (right.availableCount !== left.availableCount) {
|
||
return right.availableCount - left.availableCount
|
||
}
|
||
|
||
if (right.reservedCount !== left.reservedCount) {
|
||
return right.reservedCount - left.reservedCount
|
||
}
|
||
|
||
return left.skuCode.localeCompare(right.skuCode)
|
||
})
|
||
})
|
||
|
||
const pageOverview = computed(() => ({
|
||
skuCount: new Set(items.value.map((item) => item.skuCode)).size,
|
||
groupCount: inventoryGroups.value.length,
|
||
}))
|
||
|
||
const entrySkuSuggestions = computed(() => {
|
||
const keyword = entrySkuCode.value.trim().toLowerCase()
|
||
const suggestions = inventorySkuSuggestions.value.filter((item) => item.credentialType === entryCredentialType.value)
|
||
|
||
if (!keyword) {
|
||
return suggestions.slice(0, 12)
|
||
}
|
||
|
||
return suggestions
|
||
.filter((item) => item.skuCode.toLowerCase().includes(keyword))
|
||
.slice(0, 12)
|
||
})
|
||
|
||
function getCredentialTypeLabel(value: string) {
|
||
const normalized = String(value || '').trim()
|
||
const option = adminInventoryCredentialTypeOptions.find((item) => item.value === normalized)
|
||
return option?.label || normalized || '-'
|
||
}
|
||
|
||
function getCredentialTypeHint(value: string) {
|
||
const normalized = String(value || '').trim()
|
||
|
||
if (normalized === 'tencent_code') {
|
||
return '当前腾讯自动领取链路会优先预占这一类兑换码,三角洲行动这类场景就使用这个类型。内部 SKU 支持中文、英文和数字混用。'
|
||
}
|
||
|
||
if (normalized === 'card_password') {
|
||
return '适合卡号 / 卡密一体的库存项。'
|
||
}
|
||
|
||
if (normalized === 'account_password') {
|
||
return '适合账号密码、账号令牌等组合凭据。'
|
||
}
|
||
|
||
if (normalized === 'activation_link') {
|
||
return '适合直接发链接的激活类库存。'
|
||
}
|
||
|
||
if (normalized === 'text_credential') {
|
||
return '适合任意纯文本凭据。'
|
||
}
|
||
|
||
return '库存预占时会按凭据类型与内部履约 SKU 一起匹配。'
|
||
}
|
||
|
||
function getSkuNamingHint() {
|
||
if (entrySkuSuggestions.value.length > 0) {
|
||
return `当前凭据类型下已有 ${entrySkuSuggestions.value.length} 个可复用的内部 SKU,也可以直接输入新的中文或英文名称。`
|
||
}
|
||
|
||
return '内部 SKU 支持中文、英文、数字和常见连接符。建议同一类商品长期使用同一个命名,方便共用库存池。'
|
||
}
|
||
|
||
function formatInventoryStatus(item: AdminInventoryItemListItem) {
|
||
if (item.status === 'consumed') {
|
||
return '已完成发放'
|
||
}
|
||
|
||
if (item.status === 'reserved') {
|
||
return '已被任务预占'
|
||
}
|
||
|
||
if (item.status === 'invalid') {
|
||
return '已手动作废'
|
||
}
|
||
|
||
return '可参与后续自动分配'
|
||
}
|
||
|
||
function getGroupSuggestion(group: InventoryPoolGroup) {
|
||
if (group.availableCount > 0) {
|
||
return `当前还有 ${group.availableCount} 条可直接分配的库存,可以继续承接订单。`
|
||
}
|
||
|
||
if (group.reservedCount > 0) {
|
||
return `当前没有可用库存,但有 ${group.reservedCount} 条正在被任务预占,建议优先检查任务进度。`
|
||
}
|
||
|
||
if (group.deliveredCount > 0 && group.invalidCount === 0) {
|
||
return '当前页看到的库存都已经发放完成,若继续售卖需要补新的库存项。'
|
||
}
|
||
|
||
if (group.invalidCount > 0 && group.totalCount === group.invalidCount) {
|
||
return '当前页这组库存都已作废,建议重新补充可用库存。'
|
||
}
|
||
|
||
return '当前页没有可立即分配的库存,建议补库存或检查对应履约任务。'
|
||
}
|
||
|
||
function useGroupAsFilter(group: InventoryPoolGroup) {
|
||
filterSkuCode.value = group.skuCode
|
||
filterCredentialType.value = group.credentialType
|
||
filterInventoryGroupCode.value = group.inventoryGroupCode
|
||
void loadInventoryItems(1)
|
||
}
|
||
|
||
function useGroupAsEntry(group: InventoryPoolGroup) {
|
||
entrySkuCode.value = group.skuCode
|
||
entryCredentialType.value = group.credentialType
|
||
entryInventoryGroupCode.value = group.inventoryGroupCode
|
||
if (group.batchSummary !== '未分批' && !group.batchSummary.includes(' / ')) {
|
||
entryBatchNo.value = group.batchSummary
|
||
}
|
||
showSuccess(`已把 ${group.skuCode} / ${group.credentialType} / ${group.inventoryGroupCode || '未分组'} 填入入库表单`)
|
||
}
|
||
|
||
function applySkuSuggestion(skuCode: string) {
|
||
entrySkuCode.value = String(skuCode || '').trim()
|
||
}
|
||
|
||
function buildImportRows() {
|
||
const lines = bulkText.value
|
||
.split('\n')
|
||
.map((line) => line.trim())
|
||
.filter(Boolean)
|
||
|
||
const rows: InventoryImportRow[] = []
|
||
const defaultSkuCode = entrySkuCode.value.trim()
|
||
const defaultCredentialType = entryCredentialType.value.trim() || 'tencent_code'
|
||
const defaultBatchNo = entryBatchNo.value.trim()
|
||
|
||
for (let index = 0; index < lines.length; index += 1) {
|
||
const line = lines[index]
|
||
if (line.includes('|')) {
|
||
const parts = line.split('|').map((part) => part.trim())
|
||
|
||
if (parts.length !== 4 && parts.length !== 5) {
|
||
throw new Error(`第 ${index + 1} 行格式不正确,请使用 内部SKU|凭据类型|批次号|库存组|凭据内容`)
|
||
}
|
||
|
||
const [skuCode, credentialType, batchNo, inventoryGroupCode, displayValue] = parts.length === 5
|
||
? parts
|
||
: [parts[0], parts[1], parts[2], '', parts[3]]
|
||
if (!skuCode || !displayValue) {
|
||
throw new Error(`第 ${index + 1} 行缺少内部 SKU 或凭据内容`)
|
||
}
|
||
|
||
rows.push({
|
||
skuCode,
|
||
credentialType: credentialType || defaultCredentialType,
|
||
batchNo,
|
||
inventoryGroupCode: inventoryGroupCode || entryInventoryGroupCode.value.trim(),
|
||
displayValue,
|
||
})
|
||
continue
|
||
}
|
||
|
||
if (!defaultSkuCode) {
|
||
throw new Error('存在未带内部 SKU 的行,请先填写默认内部履约 SKU,或改用四列导入格式')
|
||
}
|
||
|
||
rows.push({
|
||
skuCode: defaultSkuCode,
|
||
credentialType: defaultCredentialType,
|
||
batchNo: defaultBatchNo,
|
||
inventoryGroupCode: entryInventoryGroupCode.value.trim(),
|
||
displayValue: line,
|
||
})
|
||
}
|
||
|
||
return rows
|
||
}
|
||
|
||
async function loadInventoryItems(page = pagination.value.page) {
|
||
loading.value = true
|
||
errorMessage.value = ''
|
||
|
||
try {
|
||
const response = await fetchAdminInventoryItems({
|
||
page,
|
||
pageSize: pagination.value.pageSize,
|
||
skuCode: filterSkuCode.value.trim(),
|
||
credentialType: filterCredentialType.value.trim(),
|
||
status: filterStatus.value.trim(),
|
||
batchNo: filterBatchNo.value.trim(),
|
||
inventoryGroupCode: filterInventoryGroupCode.value.trim(),
|
||
})
|
||
items.value = response.data.items
|
||
pagination.value = response.data.pagination
|
||
} catch (error) {
|
||
errorMessage.value = error instanceof Error ? error.message : '读取库存列表失败'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadInventorySkuSuggestions() {
|
||
skuSuggestionsLoading.value = true
|
||
|
||
try {
|
||
const response = await fetchAdminInventorySkuSuggestions({
|
||
credentialType: entryCredentialType.value.trim() || 'tencent_code',
|
||
inventoryGroupCode: entryInventoryGroupCode.value.trim(),
|
||
limit: 50,
|
||
})
|
||
inventorySkuSuggestions.value = response.data.items
|
||
} catch (error) {
|
||
showError(error instanceof Error ? error.message : '读取内部 SKU 建议失败')
|
||
} finally {
|
||
skuSuggestionsLoading.value = false
|
||
}
|
||
}
|
||
|
||
function resetFilters() {
|
||
filterSkuCode.value = ''
|
||
filterCredentialType.value = ''
|
||
filterStatus.value = ''
|
||
filterBatchNo.value = ''
|
||
filterInventoryGroupCode.value = ''
|
||
void loadInventoryItems(1)
|
||
}
|
||
|
||
async function submitCreate() {
|
||
if (!entrySkuCode.value.trim() || !singleDisplayValue.value.trim()) {
|
||
showError('请先填写内部履约 SKU 和凭据内容')
|
||
return
|
||
}
|
||
|
||
creating.value = true
|
||
|
||
try {
|
||
await createAdminInventoryItem({
|
||
skuCode: entrySkuCode.value.trim(),
|
||
credentialType: entryCredentialType.value.trim() || 'tencent_code',
|
||
batchNo: entryBatchNo.value.trim(),
|
||
inventoryGroupCode: entryInventoryGroupCode.value.trim(),
|
||
displayValue: singleDisplayValue.value.trim(),
|
||
})
|
||
|
||
showSuccess('库存项已新增')
|
||
singleDisplayValue.value = ''
|
||
await loadInventoryItems(1)
|
||
} catch (error) {
|
||
showError(error instanceof Error ? error.message : '新增库存项失败')
|
||
} finally {
|
||
creating.value = false
|
||
}
|
||
}
|
||
|
||
async function submitImport() {
|
||
const rawLines = bulkText.value
|
||
.split('\n')
|
||
.map((line) => line.trim())
|
||
.filter(Boolean)
|
||
|
||
if (rawLines.length === 0) {
|
||
showError('请至少输入一条待导入库存')
|
||
return
|
||
}
|
||
|
||
importing.value = true
|
||
|
||
try {
|
||
const rows = buildImportRows()
|
||
const response = await importAdminInventoryItems({ rows })
|
||
|
||
showSuccess(`导入完成,新增 ${response.data.created} 条,重复 ${response.data.duplicated} 条`)
|
||
bulkText.value = ''
|
||
await loadInventoryItems(1)
|
||
} catch (error) {
|
||
showError(error instanceof Error ? error.message : '导入失败')
|
||
} finally {
|
||
importing.value = false
|
||
}
|
||
}
|
||
|
||
async function runRowAction(
|
||
inventoryItemId: number,
|
||
action: () => Promise<unknown>,
|
||
successMessage: string,
|
||
confirmText: string,
|
||
) {
|
||
try {
|
||
await showConfirm(confirmText, '确认操作', {
|
||
type: 'warning',
|
||
confirmButtonText: '继续执行',
|
||
cancelButtonText: '取消',
|
||
})
|
||
} catch {
|
||
return
|
||
}
|
||
|
||
actionLoadingId.value = inventoryItemId
|
||
|
||
try {
|
||
await action()
|
||
showSuccess(successMessage)
|
||
await loadInventoryItems()
|
||
} catch (error) {
|
||
showError(error instanceof Error ? error.message : '操作失败')
|
||
} finally {
|
||
actionLoadingId.value = null
|
||
}
|
||
}
|
||
|
||
watch(entryCredentialType, () => {
|
||
void loadInventorySkuSuggestions()
|
||
})
|
||
|
||
watch(entryInventoryGroupCode, () => {
|
||
void loadInventorySkuSuggestions()
|
||
})
|
||
|
||
onMounted(async () => {
|
||
await Promise.all([
|
||
loadInventoryItems(),
|
||
loadInventorySkuSuggestions(),
|
||
])
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<section class="admin-panel">
|
||
<header class="panel-header">
|
||
<div class="panel-header-copy">
|
||
<h1>库存管理</h1>
|
||
<p>按内部履约 SKU 和凭据类型管理库存、预占和发放。</p>
|
||
</div>
|
||
<div class="header-tags">
|
||
<span class="header-tag">按 SKU + 凭据类型入池</span>
|
||
<span class="header-tag">腾讯兑换优先使用 `tencent_code`</span>
|
||
<span class="header-tag">命中库存后自动流转或转人工</span>
|
||
</div>
|
||
</header>
|
||
|
||
<section class="filter-card">
|
||
<div class="card-head">
|
||
<div>
|
||
<h2>查询库存池</h2>
|
||
<p>按内部履约 SKU、凭据类型、批次和状态快速定位库存。</p>
|
||
</div>
|
||
</div>
|
||
<div class="form-grid">
|
||
<input v-model="filterSkuCode" class="text-input" placeholder="内部履约 SKU,例如 sjz_hdl_test01" />
|
||
<select v-model="filterCredentialType" class="text-input select-input">
|
||
<option v-for="option in adminInventoryCredentialTypeOptions" :key="option.value" :value="option.value">
|
||
{{ option.label }}
|
||
</option>
|
||
</select>
|
||
<select v-model="filterStatus" class="text-input select-input">
|
||
<option v-for="option in adminInventoryStatusOptions" :key="option.value" :value="option.value">
|
||
{{ option.label }}
|
||
</option>
|
||
</select>
|
||
<input v-model="filterBatchNo" class="text-input" placeholder="批次号" />
|
||
<input v-model="filterInventoryGroupCode" class="text-input" placeholder="库存组,例如 A组" />
|
||
</div>
|
||
<div class="action-row">
|
||
<el-button round type="primary" @click="() => loadInventoryItems(1)">查询列表</el-button>
|
||
<el-button round @click="resetFilters">清空筛选</el-button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="import-card">
|
||
<div class="card-head">
|
||
<div>
|
||
<h2>入库与导入</h2>
|
||
<p>填写内部履约 SKU,不是平台侧 `sku_code`。建议同类商品长期复用同一个内部名称。</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-grid">
|
||
<div class="sku-input-wrap">
|
||
<input
|
||
v-model="entrySkuCode"
|
||
list="inventory-sku-suggestions"
|
||
class="text-input"
|
||
placeholder="内部履约 SKU,可选已有同类型,也可自定义新的中文名"
|
||
/>
|
||
<datalist id="inventory-sku-suggestions">
|
||
<option
|
||
v-for="suggestion in entrySkuSuggestions"
|
||
:key="`${suggestion.credentialType}-${suggestion.inventoryGroupCode}-${suggestion.skuCode}`"
|
||
:value="suggestion.skuCode"
|
||
:label="`${suggestion.skuCode} · ${suggestion.inventoryGroupCode || '未分组'} · 可用 ${suggestion.availableCount} / 共 ${suggestion.totalCount}`"
|
||
/>
|
||
</datalist>
|
||
</div>
|
||
<select v-model="entryCredentialType" class="text-input select-input">
|
||
<option
|
||
v-for="option in adminInventoryCredentialTypeOptions.filter((item) => item.value)"
|
||
:key="option.value"
|
||
:value="option.value"
|
||
>
|
||
{{ option.label }}
|
||
</option>
|
||
</select>
|
||
<input v-model="entryBatchNo" class="text-input" placeholder="批次号,可选" />
|
||
<input v-model="entryInventoryGroupCode" class="text-input" placeholder="库存组,例如 A组 / B组" />
|
||
</div>
|
||
|
||
<div class="sku-helper-bar">
|
||
<span class="section-tip sku-helper-copy">{{ getSkuNamingHint() }}</span>
|
||
<span v-if="skuSuggestionsLoading" class="section-tip">正在加载同类型内部 SKU…</span>
|
||
</div>
|
||
|
||
<div v-if="entrySkuSuggestions.length > 0" class="sku-chip-row">
|
||
<button
|
||
v-for="suggestion in entrySkuSuggestions"
|
||
:key="`${suggestion.credentialType}-${suggestion.inventoryGroupCode}-${suggestion.skuCode}-chip`"
|
||
type="button"
|
||
class="sku-chip"
|
||
@click="applySkuSuggestion(suggestion.skuCode)"
|
||
>
|
||
<span class="sku-chip-title">{{ suggestion.skuCode }}</span>
|
||
<span class="sku-chip-meta">{{ suggestion.inventoryGroupCode || '未分组' }} · 可用 {{ suggestion.availableCount }} / 共 {{ suggestion.totalCount }}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="helper-grid">
|
||
<div class="helper-item">
|
||
<span class="helper-title">当前默认匹配</span>
|
||
<span class="helper-copy">{{ getCredentialTypeLabel(entryCredentialType) }}</span>
|
||
</div>
|
||
<div class="helper-item">
|
||
<span class="helper-title">库存组</span>
|
||
<span class="helper-copy">{{ entryInventoryGroupCode || '未分组,默认全局可用' }}</span>
|
||
</div>
|
||
<div class="helper-item helper-item-wide">
|
||
<span class="helper-title">说明</span>
|
||
<span class="helper-copy">{{ getCredentialTypeHint(entryCredentialType) }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="single-row">
|
||
<input v-model="singleDisplayValue" class="text-input" placeholder="单条新增的凭据内容" />
|
||
<el-button :loading="creating" round type="success" @click="submitCreate">单条新增</el-button>
|
||
</div>
|
||
|
||
<textarea
|
||
v-model="bulkText"
|
||
class="text-area"
|
||
placeholder="批量导入支持两种格式: 1. 每行一个凭据内容(使用上方默认内部 SKU / 凭据类型 / 批次 / 库存组) 2. 每行一条:内部SKU|凭据类型|批次号|库存组|凭据内容"
|
||
/>
|
||
<p class="section-tip">示例:`sjz_hdl_test01|tencent_code|sjz-20260412|A组|DJQFf7HgXXXXXXXXXX`</p>
|
||
|
||
<div class="action-row">
|
||
<el-button :loading="importing" round type="primary" @click="submitImport">批量导入</el-button>
|
||
</div>
|
||
</section>
|
||
|
||
<p v-if="errorMessage" class="error-copy">{{ errorMessage }}</p>
|
||
<div v-if="loading" class="empty-block">库存列表加载中</div>
|
||
|
||
<div v-else-if="items.length === 0" class="empty-block">
|
||
当前筛选下没有库存项,可以先在上方录入内部履约 SKU 对应的库存。
|
||
</div>
|
||
|
||
<div v-else class="table-card">
|
||
<div class="summary-grid">
|
||
<article class="summary-card">
|
||
<span class="summary-label">当前页内部 SKU</span>
|
||
<strong class="summary-value">{{ pageOverview.skuCount }}</strong>
|
||
<span class="summary-copy">当前页去重后的内部履约 SKU 数量</span>
|
||
</article>
|
||
<article class="summary-card">
|
||
<span class="summary-label">当前页库存池</span>
|
||
<strong class="summary-value">{{ pageOverview.groupCount }}</strong>
|
||
<span class="summary-copy">按 内部履约 SKU + 凭据类型 聚合后的池子数量</span>
|
||
</article>
|
||
<article class="summary-card">
|
||
<span class="summary-label">可立即分配</span>
|
||
<strong class="summary-value">{{ pageSummary.availableCount }}</strong>
|
||
<span class="summary-copy">当前页还能直接被任务预占的库存项</span>
|
||
</article>
|
||
<article class="summary-card">
|
||
<span class="summary-label">预占中</span>
|
||
<strong class="summary-value">{{ pageSummary.reservedCount }}</strong>
|
||
<span class="summary-copy">当前页已经被任务锁定的库存项</span>
|
||
</article>
|
||
</div>
|
||
|
||
<section class="pool-section">
|
||
<div class="card-head pool-head">
|
||
<div>
|
||
<h2>当前页库存池概览</h2>
|
||
<p>先看哪组内部履约 SKU 还有量,再决定补库存、查任务,还是切去人工履约。</p>
|
||
</div>
|
||
</div>
|
||
<div class="pool-grid">
|
||
<article v-for="group in inventoryGroups" :key="group.key" class="pool-card">
|
||
<div class="pool-topline">
|
||
<div class="pool-title-block">
|
||
<h3 class="pool-title">{{ group.skuCode }}</h3>
|
||
<span class="pool-subtitle">
|
||
{{ getCredentialTypeLabel(group.credentialType) }} · {{ group.credentialType }} · {{ group.inventoryGroupCode || '未分组' }}
|
||
</span>
|
||
</div>
|
||
<span class="pool-total">共 {{ group.totalCount }} 条</span>
|
||
</div>
|
||
|
||
<div class="pool-stats">
|
||
<span class="pool-stat">可用 {{ group.availableCount }}</span>
|
||
<span class="pool-stat">预占 {{ group.reservedCount }}</span>
|
||
<span class="pool-stat">发放 {{ group.deliveredCount }}</span>
|
||
<span class="pool-stat">作废 {{ group.invalidCount }}</span>
|
||
</div>
|
||
|
||
<div class="pool-meta">
|
||
<span>批次 {{ group.batchSummary }}</span>
|
||
<span>最近更新 {{ formatAdminDateTime(group.latestUpdatedAt) }}</span>
|
||
</div>
|
||
|
||
<p class="pool-copy">{{ getGroupSuggestion(group) }}</p>
|
||
|
||
<div class="pool-actions">
|
||
<el-button round @click="useGroupAsFilter(group)">只看这个池</el-button>
|
||
<el-button round type="primary" @click="useGroupAsEntry(group)">作为入库目标</el-button>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
</section>
|
||
|
||
<div class="table-toolbar">
|
||
<div class="toolbar-copy">
|
||
当前筛选共 {{ pagination.total }} 条,本页可用 {{ pageSummary.availableCount }} 条,预占 {{ pageSummary.reservedCount }} 条,已发放 {{ pageSummary.deliveredCount }} 条,作废 {{ pageSummary.invalidCount }} 条。
|
||
</div>
|
||
</div>
|
||
|
||
<table class="data-table">
|
||
<colgroup>
|
||
<col class="col-item" />
|
||
<col class="col-sku" />
|
||
<col class="col-status" />
|
||
<col class="col-link" />
|
||
<col class="col-time" />
|
||
<col class="col-actions" />
|
||
</colgroup>
|
||
<thead>
|
||
<tr>
|
||
<th>库存项</th>
|
||
<th>内部履约 SKU</th>
|
||
<th>状态 / 绑定</th>
|
||
<th>关联任务 / 订单</th>
|
||
<th>时间</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="item in items" :key="item.inventoryItemId">
|
||
<td>
|
||
<div class="item-cell">
|
||
<span class="item-title">#{{ item.inventoryItemId }}</span>
|
||
<code class="item-value">{{ item.displayValue }}</code>
|
||
<span class="item-subline">批次 {{ item.batchNo || '未分批' }}</span>
|
||
<span class="item-subline">库存组 {{ item.inventoryGroupCode || '未分组' }}</span>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<div class="sku-cell">
|
||
<span class="sku-title">{{ item.skuCode }}</span>
|
||
<span class="sku-subline">{{ getCredentialTypeLabel(item.credentialType) }}</span>
|
||
<span class="sku-subline">库存组 {{ item.inventoryGroupCode || '未分组' }}</span>
|
||
<span class="sku-subline sku-raw">{{ item.credentialType || '-' }}</span>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<div class="status-cell">
|
||
<div class="status-stack">
|
||
<AdminStatusTag :status="item.status" />
|
||
<AdminStatusTag :status="item.systemBindingStatus" />
|
||
<AdminStatusTag :status="item.userBindingStatus" />
|
||
</div>
|
||
<span class="status-copy">{{ formatInventoryStatus(item) }}</span>
|
||
<span v-if="item.invalidReason" class="status-warning">原因:{{ item.invalidReason }}</span>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<div class="link-cell">
|
||
<span class="link-title">{{ item.reservedByTaskNo || '-' }}</span>
|
||
<span class="link-subline">任务号</span>
|
||
<span class="link-title">{{ item.platformOrderId || '-' }}</span>
|
||
<span class="link-subline">平台订单号</span>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<div class="time-cell">
|
||
<span class="time-line">入库 {{ formatAdminDateTime(item.createdAt) }}</span>
|
||
<span class="time-line">更新 {{ formatAdminDateTime(item.updatedAt) }}</span>
|
||
<span v-if="item.deliveredAt" class="time-line">发放 {{ formatAdminDateTime(item.deliveredAt) }}</span>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<div class="action-stack">
|
||
<el-button
|
||
v-if="hasAdminRole('admin') && item.status === 'reserved'"
|
||
:loading="actionLoadingId === item.inventoryItemId"
|
||
link
|
||
@click="runRowAction(item.inventoryItemId, () => releaseAdminInventoryItem(item.inventoryItemId), '库存项已释放', `确认释放库存项 #${item.inventoryItemId} 吗?释放后任务需要重新分配库存。`)"
|
||
>
|
||
释放
|
||
</el-button>
|
||
<el-button
|
||
v-if="hasAdminRole('admin') && item.status === 'available'"
|
||
:loading="actionLoadingId === item.inventoryItemId"
|
||
link
|
||
type="danger"
|
||
@click="runRowAction(item.inventoryItemId, () => invalidateAdminInventoryItem(item.inventoryItemId, { reason: '后台手动作废' }), '库存项已作废', `确认作废库存项 #${item.inventoryItemId} 吗?作废后不会再参与自动分配。`)"
|
||
>
|
||
作废
|
||
</el-button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<AdminPaginationBar
|
||
:page="pagination.page"
|
||
:page-size="pagination.pageSize"
|
||
:total="pagination.total"
|
||
:loading="loading"
|
||
@change="loadInventoryItems"
|
||
/>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.admin-panel {
|
||
display: grid;
|
||
gap: 14px;
|
||
}
|
||
|
||
.panel-header h1,
|
||
.card-head h2,
|
||
.pool-title {
|
||
margin: 0;
|
||
color: #1d3555;
|
||
}
|
||
|
||
.panel-header p,
|
||
.card-head p,
|
||
.toolbar-copy,
|
||
.section-tip {
|
||
margin: 8px 0 0;
|
||
color: #64748b;
|
||
}
|
||
|
||
.filter-card,
|
||
.import-card,
|
||
.table-card,
|
||
.empty-block,
|
||
.error-copy {
|
||
padding: 18px;
|
||
border-radius: 20px;
|
||
background: rgba(255, 255, 255, 0.94);
|
||
border: 1px solid rgba(86, 108, 138, 0.1);
|
||
}
|
||
|
||
.panel-header {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
|
||
.panel-header-copy p {
|
||
margin-top: 6px;
|
||
}
|
||
|
||
.header-tags {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
}
|
||
|
||
.header-tag,
|
||
.helper-title,
|
||
.summary-label,
|
||
.pool-total {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
min-height: 32px;
|
||
padding: 0 12px;
|
||
border-radius: 999px;
|
||
background: rgba(37, 89, 167, 0.08);
|
||
border: 1px solid rgba(37, 89, 167, 0.12);
|
||
color: #2559a7;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
|
||
.card-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: 12px;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.form-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
.sku-input-wrap {
|
||
display: grid;
|
||
}
|
||
|
||
.single-row,
|
||
.action-row {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: center;
|
||
margin-top: 14px;
|
||
}
|
||
|
||
.helper-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 12px;
|
||
margin-top: 14px;
|
||
}
|
||
|
||
.sku-helper-bar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
align-items: center;
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.sku-helper-copy {
|
||
margin: 0;
|
||
}
|
||
|
||
.sku-chip-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.sku-chip {
|
||
display: inline-grid;
|
||
gap: 4px;
|
||
padding: 10px 14px;
|
||
border-radius: 14px;
|
||
border: 1px solid rgba(86, 108, 138, 0.16);
|
||
background: linear-gradient(180deg, rgba(244, 248, 255, 0.92), rgba(255, 255, 255, 0.98));
|
||
color: #1d3555;
|
||
cursor: pointer;
|
||
transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease;
|
||
}
|
||
|
||
.sku-chip:hover {
|
||
transform: translateY(-1px);
|
||
border-color: rgba(37, 89, 167, 0.26);
|
||
box-shadow: 0 12px 24px rgba(37, 89, 167, 0.08);
|
||
}
|
||
|
||
.sku-chip-title {
|
||
font-weight: 700;
|
||
}
|
||
|
||
.sku-chip-meta {
|
||
color: #64748b;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.helper-item {
|
||
display: grid;
|
||
gap: 6px;
|
||
padding: 14px 16px;
|
||
border-radius: 16px;
|
||
background: rgba(244, 248, 255, 0.84);
|
||
border: 1px solid rgba(86, 108, 138, 0.08);
|
||
}
|
||
|
||
.helper-item-wide {
|
||
grid-column: span 1;
|
||
}
|
||
|
||
.helper-copy {
|
||
color: #334155;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.error-copy {
|
||
color: #b42318;
|
||
}
|
||
|
||
.text-input,
|
||
.text-area {
|
||
width: 100%;
|
||
min-height: 42px;
|
||
padding: 0 14px;
|
||
border-radius: 14px;
|
||
border: 1px solid rgba(86, 108, 138, 0.18);
|
||
background: rgba(255, 255, 255, 0.94);
|
||
}
|
||
|
||
.select-input {
|
||
appearance: none;
|
||
}
|
||
|
||
.text-area {
|
||
min-height: 180px;
|
||
padding: 14px;
|
||
margin-top: 14px;
|
||
resize: vertical;
|
||
font-family: var(--font-mono);
|
||
}
|
||
|
||
.table-toolbar {
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.summary-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.summary-card,
|
||
.pool-card {
|
||
padding: 16px;
|
||
border-radius: 18px;
|
||
border: 1px solid rgba(86, 108, 138, 0.08);
|
||
background: rgba(248, 250, 252, 0.8);
|
||
}
|
||
|
||
.summary-card {
|
||
display: grid;
|
||
gap: 8px;
|
||
}
|
||
|
||
.summary-value {
|
||
font-size: 30px;
|
||
line-height: 1;
|
||
color: #1d3555;
|
||
}
|
||
|
||
.summary-copy {
|
||
color: #64748b;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.pool-section {
|
||
display: grid;
|
||
gap: 14px;
|
||
margin-bottom: 18px;
|
||
}
|
||
|
||
.pool-head {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.pool-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
.pool-card {
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
|
||
.pool-topline {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.pool-title-block {
|
||
display: grid;
|
||
gap: 6px;
|
||
}
|
||
|
||
.pool-title {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
color: #1d3555;
|
||
}
|
||
|
||
.pool-subtitle,
|
||
.pool-meta,
|
||
.pool-copy {
|
||
color: #64748b;
|
||
}
|
||
|
||
.pool-stats {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.pool-stat {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
min-height: 30px;
|
||
padding: 0 10px;
|
||
border-radius: 999px;
|
||
background: rgba(37, 89, 167, 0.08);
|
||
color: #1d4f91;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.pool-meta {
|
||
display: grid;
|
||
gap: 6px;
|
||
}
|
||
|
||
.pool-copy {
|
||
margin: 0;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.pool-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
}
|
||
|
||
.data-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
}
|
||
|
||
.data-table th,
|
||
.data-table td {
|
||
padding: 12px 10px;
|
||
text-align: left;
|
||
border-bottom: 1px solid rgba(86, 108, 138, 0.08);
|
||
color: #334155;
|
||
vertical-align: top;
|
||
}
|
||
|
||
.data-table th {
|
||
color: #64748b;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.item-cell,
|
||
.sku-cell,
|
||
.status-cell,
|
||
.link-cell,
|
||
.time-cell {
|
||
display: grid;
|
||
gap: 6px;
|
||
}
|
||
|
||
.item-title,
|
||
.sku-title,
|
||
.link-title {
|
||
font-weight: 700;
|
||
color: #1d3555;
|
||
}
|
||
|
||
.item-value {
|
||
display: inline-flex;
|
||
width: fit-content;
|
||
max-width: 100%;
|
||
padding: 6px 8px;
|
||
border-radius: 10px;
|
||
background: rgba(15, 23, 42, 0.06);
|
||
color: #0f172a;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.item-subline,
|
||
.sku-subline,
|
||
.link-subline,
|
||
.time-line,
|
||
.status-copy {
|
||
color: #64748b;
|
||
}
|
||
|
||
.sku-raw {
|
||
font-family: var(--font-mono);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.status-stack,
|
||
.action-stack {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
.status-warning {
|
||
color: #b54708;
|
||
}
|
||
|
||
.empty-block {
|
||
color: #64748b;
|
||
}
|
||
|
||
.col-item {
|
||
width: 24%;
|
||
}
|
||
|
||
.col-sku {
|
||
width: 18%;
|
||
}
|
||
|
||
.col-status {
|
||
width: 20%;
|
||
}
|
||
|
||
.col-link {
|
||
width: 14%;
|
||
}
|
||
|
||
.col-time {
|
||
width: 16%;
|
||
}
|
||
|
||
.col-actions {
|
||
width: 8%;
|
||
}
|
||
|
||
@media (max-width: 1180px) {
|
||
.summary-grid,
|
||
.pool-grid,
|
||
.form-grid,
|
||
.helper-grid {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
}
|
||
|
||
@media (max-width: 860px) {
|
||
.summary-grid,
|
||
.pool-grid,
|
||
.form-grid,
|
||
.helper-grid {
|
||
grid-template-columns: minmax(0, 1fr);
|
||
}
|
||
|
||
.header-tags,
|
||
.single-row,
|
||
.action-row,
|
||
.sku-helper-bar {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
|
||
.table-card {
|
||
overflow-x: auto;
|
||
}
|
||
|
||
.data-table {
|
||
min-width: 980px;
|
||
}
|
||
}
|
||
</style>
|