Files
order_site/apps/frontend/src/views/admin/AdminInventoryView.vue
T
2026-04-23 13:38:21 +08:00

1223 lines
34 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 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="批量导入支持两种格式:&#10;1. 每行一个凭据内容(使用上方默认内部 SKU / 凭据类型 / 批次 / 库存组)&#10;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>