[Snow Team] inventory-users-refactor: auto-commit work

This commit is contained in:
yml2213
2026-05-17 09:45:55 +08:00
parent a77198b218
commit 52b0c9b93b
12 changed files with 2049 additions and 1590 deletions
@@ -1,364 +0,0 @@
@import '../../../styles/admin-list-pages.css';
.form-grid {
display: grid;
grid-template-columns: 1.3fr 0.95fr 0.85fr 1fr 1fr;
gap: var(--space-3);
}
.sku-input-wrap {
display: grid;
min-width: 0;
}
.native-input {
width: 100%;
min-width: 0;
min-height: 40px;
padding: 0 14px;
border-radius: var(--radius-md);
border: 1px solid var(--border-default);
background: rgba(255, 255, 255, 0.74);
color: var(--text-primary);
font-size: var(--text-sm);
outline: none;
transition:
border-color 0.16s ease,
box-shadow 0.16s ease;
}
.native-input:focus {
border-color: rgba(37, 89, 167, 0.38);
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 0 0 3px rgba(37, 89, 167, 0.08);
}
.single-row,
.action-row {
display: flex;
gap: var(--space-3);
align-items: center;
margin-top: 14px;
}
.single-row :deep(.el-input) {
min-width: 0;
}
.helper-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
margin-top: 14px;
}
.helper-item {
display: grid;
gap: 6px;
padding: 12px 14px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: var(--radius-md);
background: rgba(248, 250, 252, 0.72);
}
.helper-title {
color: #2559a7;
font-size: var(--text-xs);
font-weight: 700;
}
.helper-copy {
min-width: 0;
color: #334155;
line-height: 1.6;
word-break: break-word;
}
.sku-helper-bar {
display: flex;
justify-content: space-between;
gap: var(--space-3);
align-items: center;
margin-top: var(--space-3);
}
.helper-text {
color: var(--text-secondary);
font-size: var(--text-xs);
margin: 0;
}
.sku-chip-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: var(--space-3);
}
.sku-chip {
display: inline-grid;
gap: 4px;
max-width: 100%;
padding: 10px 14px;
border-radius: var(--radius-md);
border: 1px solid rgba(86, 108, 138, 0.16);
background: rgba(248, 250, 252, 0.78);
color: var(--text-primary);
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 {
min-width: 0;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sku-chip-meta {
min-width: 0;
color: var(--text-secondary);
font-size: var(--text-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.stat-card {
min-width: 0;
padding: 14px 16px;
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: var(--radius-md);
background: rgba(248, 250, 252, 0.72);
}
.stat-label {
color: var(--text-secondary);
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.04em;
}
.stat-value {
font-size: 30px;
line-height: 1;
color: var(--text-primary);
display: block;
margin-top: var(--space-2);
}
.stat-meta {
color: var(--text-secondary);
font-size: var(--text-xs);
display: block;
margin-top: var(--space-1);
}
.pool-section {
margin-bottom: 18px;
}
.pool-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.pool-card {
display: grid;
gap: var(--space-3);
padding: var(--space-4);
border-radius: var(--radius-md);
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(248, 250, 252, 0.68);
}
.pool-topline {
display: flex;
justify-content: space-between;
gap: var(--space-3);
align-items: flex-start;
}
.pool-title-block {
display: grid;
gap: 6px;
min-width: 0;
}
.pool-title {
min-width: 0;
margin: 0;
color: var(--text-primary);
font-size: var(--text-base);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pool-subtitle {
min-width: 0;
color: var(--text-secondary);
font-size: var(--text-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pool-total {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
min-height: 30px;
padding: 0 10px;
border-radius: var(--radius-full);
background: rgba(37, 89, 167, 0.08);
border: 1px solid rgba(37, 89, 167, 0.12);
color: #2559a7;
font-size: var(--text-xs);
font-weight: 700;
}
.pool-stats {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.pool-stat {
display: inline-flex;
align-items: center;
min-height: 30px;
padding: 0 10px;
border-radius: var(--radius-full);
background: rgba(37, 89, 167, 0.08);
color: #1d4f91;
font-size: var(--text-xs);
font-weight: 700;
}
.pool-meta {
display: grid;
gap: 6px;
min-width: 0;
color: var(--text-secondary);
font-size: var(--text-xs);
word-break: break-word;
}
.pool-suggestion {
margin: 0;
line-height: 1.6;
color: var(--text-secondary);
font-size: var(--text-sm);
}
.pool-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.table-toolbar {
margin-bottom: var(--space-2);
padding-top: var(--space-2);
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: 1.6;
word-break: break-word;
}
.item-cell,
.sku-cell,
.info-cell {
display: grid;
gap: 6px;
}
.item-title,
.sku-title,
.link-title {
color: var(--text-primary);
font-weight: 700;
}
.item-value {
display: inline-flex;
max-width: 100%;
width: fit-content;
padding: 6px 8px;
border-radius: var(--radius-sm);
background: rgba(15, 23, 42, 0.06);
color: #0f172a;
white-space: pre-wrap;
word-break: break-all;
}
.info-subline,
.sku-subline,
.link-subline,
.time-line,
.status-copy {
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: 1.5;
}
.sku-raw {
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.status-stack,
.action-stack {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
align-items: center;
}
.action-stack {
justify-content: center;
}
.status-warning {
color: #b54708;
font-size: var(--text-xs);
}
@media (max-width: 1200px) {
.form-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.form-grid,
.summary-grid,
.pool-grid,
.helper-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.card-header {
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 640px) {
.form-grid,
.summary-grid,
.pool-grid,
.helper-grid {
grid-template-columns: 1fr;
}
.single-row,
.action-row,
.sku-helper-bar {
flex-direction: column;
align-items: stretch;
}
.status-stack,
.action-stack {
justify-content: flex-start;
}
}
@@ -1,407 +1,58 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import AdminPageHeader from '@/components/admin/AdminPageHeader.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
}
import AdminInventoryEntryCard from './components/AdminInventoryEntryCard.vue'
import AdminInventoryFilterCard from './components/AdminInventoryFilterCard.vue'
import AdminInventoryPoolGrid from './components/AdminInventoryPoolGrid.vue'
import AdminInventoryTable from './components/AdminInventoryTable.vue'
import { useAdminInventory } from './composables/useAdminInventory'
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()])
})
const {
loading,
importing,
creating,
actionLoadingId,
errorMessage,
items,
pagination,
pageSummary,
pageOverview,
inventoryGroups,
// filter
filterSkuCode,
filterCredentialType,
filterStatus,
filterBatchNo,
filterInventoryGroupCode,
// entry
entrySkuCode,
entryCredentialType,
entryBatchNo,
entryInventoryGroupCode,
singleDisplayValue,
bulkText,
skuSuggestionsLoading,
inventorySkuSuggestions,
entrySkuSuggestions,
// actions
loadInventoryItems,
resetFilters,
submitCreate,
submitImport,
runRowAction,
useGroupAsFilter,
useGroupAsEntry,
applySkuSuggestion,
// formatters
getCredentialTypeLabel,
getCredentialTypeHint,
getSkuNamingHint,
formatInventoryStatus,
getGroupSuggestion,
// utils
hasAdminRole,
formatAdminDateTime,
} = useAdminInventory()
</script>
<template>
@@ -412,162 +63,34 @@ onMounted(async () => {
</template>
</AdminPageHeader>
<el-card shadow="never" class="section-card">
<template #header>
<div class="card-header">
<span class="card-title">查询库存池</span>
<span class="card-desc">按内部履约 SKU凭据类型批次和状态快速定位库存</span>
</div>
</template>
<div class="form-grid">
<el-input
v-model="filterSkuCode"
placeholder="内部履约 SKU,例如 sjz_hdl_test01"
clearable
@keyup.enter="loadInventoryItems(1)"
<AdminInventoryFilterCard
v-model:filter-sku-code="filterSkuCode"
v-model:filter-credential-type="filterCredentialType"
v-model:filter-status="filterStatus"
v-model:filter-batch-no="filterBatchNo"
v-model:filter-inventory-group-code="filterInventoryGroupCode"
:load-inventory-items="loadInventoryItems"
:reset-filters="resetFilters"
/>
<el-select v-model="filterCredentialType" placeholder="凭据类型" clearable>
<el-option
v-for="opt in adminInventoryCredentialTypeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-select v-model="filterStatus" placeholder="状态" clearable>
<el-option
v-for="opt in adminInventoryStatusOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-input
v-model="filterBatchNo"
placeholder="批次号"
clearable
@keyup.enter="loadInventoryItems(1)"
/>
<el-input
v-model="filterInventoryGroupCode"
placeholder="库存组,例如 A组"
clearable
@keyup.enter="loadInventoryItems(1)"
/>
</div>
<div class="action-row">
<el-button round type="primary" @click="loadInventoryItems(1)">查询列表</el-button>
<el-button round @click="resetFilters">清空筛选</el-button>
</div>
</el-card>
<el-card shadow="never" class="section-card">
<template #header>
<div class="card-header">
<span class="card-title">入库与导入</span>
<span class="card-desc"
>填写内部履约 SKU不是平台侧 sku_code建议同类商品长期复用同一个内部名称</span
>
</div>
</template>
<div class="form-grid">
<div class="sku-input-wrap">
<input
v-model="entrySkuCode"
list="inventory-sku-suggestions"
class="native-input"
placeholder="内部履约 SKU,可选已有同类型,也可自定义新的中文名"
<AdminInventoryEntryCard
v-model:entry-sku-code="entrySkuCode"
v-model:entry-credential-type="entryCredentialType"
v-model:entry-batch-no="entryBatchNo"
v-model:entry-inventory-group-code="entryInventoryGroupCode"
v-model:single-display-value="singleDisplayValue"
v-model:bulk-text="bulkText"
:creating="creating"
:importing="importing"
:sku-suggestions-loading="skuSuggestionsLoading"
:entry-sku-suggestions="entrySkuSuggestions"
:submit-create="submitCreate"
:submit-import="submitImport"
:apply-sku-suggestion="applySkuSuggestion"
:get-credential-type-label="getCredentialTypeLabel"
:get-credential-type-hint="getCredentialTypeHint"
:get-sku-naming-hint="getSkuNamingHint"
/>
<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>
<el-select v-model="entryCredentialType" placeholder="凭据类型">
<el-option
v-for="opt in adminInventoryCredentialTypeOptions.filter((item) => item.value)"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-input v-model="entryBatchNo" placeholder="批次号,可选" clearable />
<el-input
v-model="entryInventoryGroupCode"
placeholder="库存组,例如 A组 / B组"
clearable
/>
</div>
<div class="sku-helper-bar">
<span class="helper-text">{{ getSkuNamingHint() }}</span>
<span v-if="skuSuggestionsLoading" class="helper-text">正在加载同类型内部 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">
<el-input
v-model="singleDisplayValue"
placeholder="单条新增的凭据内容"
@keyup.enter="submitCreate"
/>
<el-button :loading="creating" round type="success" @click="submitCreate"
>单条新增</el-button
>
</div>
<el-input
v-model="bulkText"
type="textarea"
:rows="8"
placeholder="批量导入支持两种格式:&#10;1. 每行一个凭据内容(使用上方默认内部 SKU / 凭据类型 / 批次 / 库存组)&#10;2. 每行一条:内部SKU|凭据类型|批次号|库存组|凭据内容"
style="margin-top: 14px"
/>
<p class="helper-text" style="margin-top: 8px">
示例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>
</el-card>
<el-alert
v-if="errorMessage"
@@ -592,198 +115,34 @@ onMounted(async () => {
</template>
<template v-else>
<div class="summary-grid">
<div class="stat-card">
<span class="stat-label">当前页内部 SKU</span>
<strong class="stat-value">{{ pageOverview.skuCount }}</strong>
<span class="stat-meta">当前页去重后的内部履约 SKU 数量</span>
</div>
<div class="stat-card">
<span class="stat-label">当前页库存池</span>
<strong class="stat-value">{{ pageOverview.groupCount }}</strong>
<span class="stat-meta"> 内部履约 SKU + 凭据类型 聚合后的池子数量</span>
</div>
<div class="stat-card">
<span class="stat-label">可立即分配</span>
<strong class="stat-value">{{ pageSummary.availableCount }}</strong>
<span class="stat-meta">当前页还能直接被任务预占的库存项</span>
</div>
<div class="stat-card">
<span class="stat-label">预占中</span>
<strong class="stat-value">{{ pageSummary.reservedCount }}</strong>
<span class="stat-meta">当前页已经被任务锁定的库存项</span>
</div>
</div>
<AdminInventoryPoolGrid
:inventory-groups="inventoryGroups"
:page-overview="pageOverview"
:page-summary="pageSummary"
:use-group-as-filter="useGroupAsFilter"
:use-group-as-entry="useGroupAsEntry"
:get-credential-type-label="getCredentialTypeLabel"
:get-group-suggestion="getGroupSuggestion"
:format-admin-date-time="formatAdminDateTime"
/>
<div class="pool-section">
<div class="card-header" style="margin-bottom: 14px">
<span class="card-title">当前页库存池概览</span>
<span class="card-desc"
>先看哪组内部履约 SKU 还有量再决定补库存查任务还是切去人工履约</span
>
</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">
<h4 class="pool-title">{{ group.skuCode }}</h4>
<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-suggestion">{{ 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>
</div>
<div class="table-toolbar">
当前筛选共 {{ pagination.total }} 本页可用 {{ pageSummary.availableCount }} 预占
{{ pageSummary.reservedCount }} 已发放 {{ pageSummary.deliveredCount }} 作废
{{ pageSummary.invalidCount }}
</div>
<el-table :data="items" stripe size="small" class="inventory-table data-table">
<el-table-column label="库存项" min-width="200">
<template #default="{ row }">
<div class="item-cell">
<RouterLink
v-if="row.reservedByTaskNo"
:to="`/admin/tasks/${row.reservedByTaskNo}`"
class="item-title"
>#{{ row.inventoryItemId }}</RouterLink
>
<span v-else class="item-title">#{{ row.inventoryItemId }}</span>
<code class="item-value">{{ row.displayValue }}</code>
</div>
</template>
</el-table-column>
<el-table-column label="内部履约 SKU" min-width="160">
<template #default="{ row }">
<div class="sku-cell">
<span class="sku-title">{{ row.skuCode }}</span>
<span class="sku-subline">{{ getCredentialTypeLabel(row.credentialType) }}</span>
<span class="sku-subline sku-raw">{{ row.credentialType || '-' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="批次 / 库存组" min-width="160">
<template #default="{ row }">
<div class="info-cell">
<span class="info-subline">批次 {{ row.batchNo || '未分批' }}</span>
<span class="info-subline">库存组 {{ row.inventoryGroupCode || '未分组' }}</span>
<span class="status-copy">{{ formatInventoryStatus(row) }}</span>
<span v-if="row.invalidReason" class="status-warning"
>原因{{ row.invalidReason }}</span
>
</div>
</template>
</el-table-column>
<el-table-column label="状态 / 绑定" min-width="180">
<template #default="{ row }">
<div class="status-stack">
<AdminStatusTag :status="row.status" />
<AdminStatusTag :status="row.systemBindingStatus" />
<AdminStatusTag :status="row.userBindingStatus" />
</div>
</template>
</el-table-column>
<el-table-column label="关联任务 / 订单" min-width="160">
<template #default="{ row }">
<div class="info-cell">
<span class="link-title">{{ row.reservedByTaskNo || '-' }}</span>
<span class="link-subline">任务号</span>
<span class="link-title">{{ row.platformOrderId || '-' }}</span>
<span class="link-subline">平台订单号</span>
</div>
</template>
</el-table-column>
<el-table-column label="时间" min-width="180">
<template #default="{ row }">
<div class="info-cell">
<span class="time-line">入库 {{ formatAdminDateTime(row.createdAt) }}</span>
<span class="time-line">更新 {{ formatAdminDateTime(row.updatedAt) }}</span>
<span v-if="row.deliveredAt" class="time-line"
>发放 {{ formatAdminDateTime(row.deliveredAt) }}</span
>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="120" align="center">
<template #default="{ row }">
<div class="action-stack">
<el-button
v-if="hasAdminRole('admin') && row.status === 'reserved'"
:loading="actionLoadingId === row.inventoryItemId"
size="small"
@click="
runRowAction(
row.inventoryItemId,
() => releaseAdminInventoryItem(row.inventoryItemId),
'库存项已释放',
`确认释放库存项 #${row.inventoryItemId} 吗?释放后任务需要重新分配库存。`,
)
"
>释放</el-button
>
<el-button
v-if="hasAdminRole('admin') && row.status === 'available'"
:loading="actionLoadingId === row.inventoryItemId"
size="small"
type="danger"
plain
@click="
runRowAction(
row.inventoryItemId,
() =>
invalidateAdminInventoryItem(row.inventoryItemId, {
reason: '后台手动作废',
}),
'库存项已作废',
`确认作废库存项 #${row.inventoryItemId} 吗?作废后不会再参与自动分配。`,
)
"
>作废</el-button
>
</div>
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="pagination.total > 0"
v-model:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
layout="total, prev, pager, next"
class="pagination-center"
@current-change="loadInventoryItems"
<AdminInventoryTable
:loading="loading"
:items="items"
:pagination="pagination"
:page-summary="pageSummary"
:action-loading-id="actionLoadingId"
:load-inventory-items="loadInventoryItems"
:run-row-action="runRowAction"
:format-inventory-status="formatInventoryStatus"
:get-credential-type-label="getCredentialTypeLabel"
:format-admin-date-time="formatAdminDateTime"
/>
</template>
</el-card>
</div>
</template>
<style scoped src="./AdminInventoryView.css"></style>
<style scoped>
@import '../../styles/admin-list-pages.css';
</style>
@@ -0,0 +1,302 @@
<script setup lang="ts">
import { adminInventoryCredentialTypeOptions } from '@/utils/admin-options'
const entrySkuCode = defineModel<string>('entrySkuCode', { required: true })
const entryCredentialType = defineModel<string>('entryCredentialType', { required: true })
const entryBatchNo = defineModel<string>('entryBatchNo', { required: true })
const entryInventoryGroupCode = defineModel<string>('entryInventoryGroupCode', { required: true })
const singleDisplayValue = defineModel<string>('singleDisplayValue', { required: true })
const bulkText = defineModel<string>('bulkText', { required: true })
defineProps<{
creating: boolean
importing: boolean
skuSuggestionsLoading: boolean
entrySkuSuggestions: Array<{
credentialType: string
inventoryGroupCode: string
skuCode: string
availableCount: number
totalCount: number
}>
submitCreate: () => Promise<void>
submitImport: () => Promise<void>
applySkuSuggestion: (skuCode: string) => void
getCredentialTypeLabel: (value: string) => string
getCredentialTypeHint: (value: string) => string
getSkuNamingHint: () => string
}>()
</script>
<template>
<el-card shadow="never" class="section-card">
<template #header>
<div class="card-header">
<span class="card-title">入库与导入</span>
<span class="card-desc"
>填写内部履约 SKU不是平台侧 sku_code建议同类商品长期复用同一个内部名称</span
>
</div>
</template>
<div class="form-grid">
<div class="sku-input-wrap">
<input
v-model="entrySkuCode"
list="inventory-sku-suggestions"
class="native-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>
<el-select v-model="entryCredentialType" placeholder="凭据类型">
<el-option
v-for="opt in adminInventoryCredentialTypeOptions.filter((item) => item.value)"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-input v-model="entryBatchNo" placeholder="批次号,可选" clearable />
<el-input
v-model="entryInventoryGroupCode"
placeholder="库存组,例如 A组 / B组"
clearable
/>
</div>
<div class="sku-helper-bar">
<span class="helper-text">{{ getSkuNamingHint() }}</span>
<span v-if="skuSuggestionsLoading" class="helper-text">正在加载同类型内部 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">
<el-input
v-model="singleDisplayValue"
placeholder="单条新增的凭据内容"
@keyup.enter="submitCreate"
/>
<el-button :loading="creating" round type="success" @click="submitCreate"
>单条新增</el-button
>
</div>
<el-input
v-model="bulkText"
type="textarea"
:rows="8"
placeholder="批量导入支持两种格式:&#10;1. 每行一个凭据内容(使用上方默认内部 SKU / 凭据类型 / 批次 / 库存组)&#10;2. 每行一条:内部SKU|凭据类型|批次号|库存组|凭据内容"
style="margin-top: 14px"
/>
<p class="helper-text" style="margin-top: 8px">
示例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>
</el-card>
</template>
<style scoped>
@import '../../../../styles/admin-list-pages.css';
.form-grid {
display: grid;
grid-template-columns: 1.3fr 0.95fr 0.85fr 1fr 1fr;
gap: var(--space-3);
}
.sku-input-wrap {
display: grid;
min-width: 0;
}
.native-input {
width: 100%;
min-width: 0;
min-height: 40px;
padding: 0 14px;
border-radius: var(--radius-md);
border: 1px solid var(--border-default);
background: rgba(255, 255, 255, 0.74);
color: var(--text-primary);
font-size: var(--text-sm);
outline: none;
transition:
border-color 0.16s ease,
box-shadow 0.16s ease;
}
.native-input:focus {
border-color: rgba(37, 89, 167, 0.38);
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 0 0 3px rgba(37, 89, 167, 0.08);
}
.single-row,
.action-row {
display: flex;
gap: var(--space-3);
align-items: center;
margin-top: 14px;
}
.single-row :deep(.el-input) {
min-width: 0;
}
.helper-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
margin-top: 14px;
}
.helper-item {
display: grid;
gap: 6px;
padding: 12px 14px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: var(--radius-md);
background: rgba(248, 250, 252, 0.72);
}
.helper-title {
color: #2559a7;
font-size: var(--text-xs);
font-weight: 700;
}
.helper-copy {
min-width: 0;
color: #334155;
line-height: 1.6;
word-break: break-word;
}
.sku-helper-bar {
display: flex;
justify-content: space-between;
gap: var(--space-3);
align-items: center;
margin-top: var(--space-3);
}
.helper-text {
color: var(--text-secondary);
font-size: var(--text-xs);
margin: 0;
}
.sku-chip-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: var(--space-3);
}
.sku-chip {
display: inline-grid;
gap: 4px;
max-width: 100%;
padding: 10px 14px;
border-radius: var(--radius-md);
border: 1px solid rgba(86, 108, 138, 0.16);
background: rgba(248, 250, 252, 0.78);
color: var(--text-primary);
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 {
min-width: 0;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sku-chip-meta {
min-width: 0;
color: var(--text-secondary);
font-size: var(--text-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 1200px) {
.form-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.form-grid,
.helper-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.card-header {
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 640px) {
.form-grid,
.helper-grid {
grid-template-columns: 1fr;
}
.single-row,
.action-row,
.sku-helper-bar {
flex-direction: column;
align-items: stretch;
}
}
</style>
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { adminInventoryCredentialTypeOptions, adminInventoryStatusOptions } from '@/utils/admin-options'
const filterSkuCode = defineModel<string>('filterSkuCode', { required: true })
const filterCredentialType = defineModel<string>('filterCredentialType', { required: true })
const filterStatus = defineModel<string>('filterStatus', { required: true })
const filterBatchNo = defineModel<string>('filterBatchNo', { required: true })
const filterInventoryGroupCode = defineModel<string>('filterInventoryGroupCode', { required: true })
defineProps<{
loadInventoryItems: (page?: number) => Promise<void>
resetFilters: () => void
}>()
</script>
<template>
<el-card shadow="never" class="section-card">
<template #header>
<div class="card-header">
<span class="card-title">查询库存池</span>
<span class="card-desc">按内部履约 SKU凭据类型批次和状态快速定位库存</span>
</div>
</template>
<div class="form-grid">
<el-input
v-model="filterSkuCode"
placeholder="内部履约 SKU,例如 sjz_hdl_test01"
clearable
@keyup.enter="loadInventoryItems(1)"
/>
<el-select v-model="filterCredentialType" placeholder="凭据类型" clearable>
<el-option
v-for="opt in adminInventoryCredentialTypeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-select v-model="filterStatus" placeholder="状态" clearable>
<el-option
v-for="opt in adminInventoryStatusOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-input
v-model="filterBatchNo"
placeholder="批次号"
clearable
@keyup.enter="loadInventoryItems(1)"
/>
<el-input
v-model="filterInventoryGroupCode"
placeholder="库存组,例如 A组"
clearable
@keyup.enter="loadInventoryItems(1)"
/>
</div>
<div class="action-row">
<el-button round type="primary" @click="loadInventoryItems(1)">查询列表</el-button>
<el-button round @click="resetFilters">清空筛选</el-button>
</div>
</el-card>
</template>
<style scoped>
@import '../../../../styles/admin-list-pages.css';
.form-grid {
display: grid;
grid-template-columns: 1.3fr 0.95fr 0.85fr 1fr 1fr;
gap: var(--space-3);
}
.action-row {
display: flex;
gap: var(--space-3);
align-items: center;
margin-top: 14px;
}
@media (max-width: 1200px) {
.form-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.card-header {
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 640px) {
.form-grid {
grid-template-columns: 1fr;
}
.action-row {
flex-direction: column;
align-items: stretch;
}
}
</style>
@@ -0,0 +1,234 @@
<script setup lang="ts">
import type { InventoryPoolGroup } from '../composables/types'
defineProps<{
inventoryGroups: InventoryPoolGroup[]
pageOverview: { skuCount: number; groupCount: number }
pageSummary: { availableCount: number; reservedCount: number; deliveredCount: number; invalidCount: number }
useGroupAsFilter: (group: InventoryPoolGroup) => void
useGroupAsEntry: (group: InventoryPoolGroup) => void
getCredentialTypeLabel: (value: string) => string
getGroupSuggestion: (group: InventoryPoolGroup) => string
formatAdminDateTime: (value: string) => string
}>()
</script>
<template>
<div class="summary-grid">
<div class="stat-card">
<span class="stat-label">当前页内部 SKU</span>
<strong class="stat-value">{{ pageOverview.skuCount }}</strong>
<span class="stat-meta">当前页去重后的内部履约 SKU 数量</span>
</div>
<div class="stat-card">
<span class="stat-label">当前页库存池</span>
<strong class="stat-value">{{ pageOverview.groupCount }}</strong>
<span class="stat-meta"> 内部履约 SKU + 凭据类型 聚合后的池子数量</span>
</div>
<div class="stat-card">
<span class="stat-label">可立即分配</span>
<strong class="stat-value">{{ pageSummary.availableCount }}</strong>
<span class="stat-meta">当前页还能直接被任务预占的库存项</span>
</div>
<div class="stat-card">
<span class="stat-label">预占中</span>
<strong class="stat-value">{{ pageSummary.reservedCount }}</strong>
<span class="stat-meta">当前页已经被任务锁定的库存项</span>
</div>
</div>
<div class="pool-section">
<div class="card-header" style="margin-bottom: 14px">
<span class="card-title">当前页库存池概览</span>
<span class="card-desc"
>先看哪组内部履约 SKU 还有量再决定补库存查任务还是切去人工履约</span
>
</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">
<h4 class="pool-title">{{ group.skuCode }}</h4>
<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-suggestion">{{ 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>
</div>
</template>
<style scoped>
.summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.stat-card {
min-width: 0;
padding: 14px 16px;
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: var(--radius-md);
background: rgba(248, 250, 252, 0.72);
}
.stat-label {
color: var(--text-secondary);
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.04em;
}
.stat-value {
font-size: 30px;
line-height: 1;
color: var(--text-primary);
display: block;
margin-top: var(--space-2);
}
.stat-meta {
color: var(--text-secondary);
font-size: var(--text-xs);
display: block;
margin-top: var(--space-1);
}
.pool-section {
margin-bottom: 18px;
}
.pool-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.pool-card {
display: grid;
gap: var(--space-3);
padding: var(--space-4);
border-radius: var(--radius-md);
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(248, 250, 252, 0.68);
}
.pool-topline {
display: flex;
justify-content: space-between;
gap: var(--space-3);
align-items: flex-start;
}
.pool-title-block {
display: grid;
gap: 6px;
min-width: 0;
}
.pool-title {
min-width: 0;
margin: 0;
color: var(--text-primary);
font-size: var(--text-base);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pool-subtitle {
min-width: 0;
color: var(--text-secondary);
font-size: var(--text-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pool-total {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
min-height: 30px;
padding: 0 10px;
border-radius: var(--radius-full);
background: rgba(37, 89, 167, 0.08);
border: 1px solid rgba(37, 89, 167, 0.12);
color: #2559a7;
font-size: var(--text-xs);
font-weight: 700;
}
.pool-stats {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.pool-stat {
display: inline-flex;
align-items: center;
min-height: 30px;
padding: 0 10px;
border-radius: var(--radius-full);
background: rgba(37, 89, 167, 0.08);
color: #1d4f91;
font-size: var(--text-xs);
font-weight: 700;
}
.pool-meta {
display: grid;
gap: 6px;
min-width: 0;
color: var(--text-secondary);
font-size: var(--text-xs);
word-break: break-word;
}
.pool-suggestion {
margin: 0;
line-height: 1.6;
color: var(--text-secondary);
font-size: var(--text-sm);
}
.pool-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
@media (max-width: 1200px) {
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.summary-grid,
.pool-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.summary-grid,
.pool-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,222 @@
<script setup lang="ts">
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
import type { AdminInventoryItemListItem } from '@/types/admin'
import { hasAdminRole } from '@/utils/admin-auth'
import { invalidateAdminInventoryItem, releaseAdminInventoryItem } from '@/services/admin'
defineProps<{
loading: boolean
items: AdminInventoryItemListItem[]
pagination: { page: number; pageSize: number; total: number }
pageSummary: { availableCount: number; reservedCount: number; deliveredCount: number; invalidCount: number }
actionLoadingId: number | null
loadInventoryItems: (page?: number) => Promise<void>
runRowAction: (id: number, action: () => Promise<unknown>, msg: string, confirm: string) => Promise<void>
formatInventoryStatus: (item: AdminInventoryItemListItem) => string
getCredentialTypeLabel: (value: string) => string
formatAdminDateTime: (value: string) => string
}>()
</script>
<template>
<div class="table-toolbar">
当前筛选共 {{ pagination.total }} 本页可用 {{ pageSummary.availableCount }} 预占
{{ pageSummary.reservedCount }} 已发放 {{ pageSummary.deliveredCount }} 作废
{{ pageSummary.invalidCount }}
</div>
<el-table :data="items" stripe size="small" class="inventory-table data-table">
<el-table-column label="库存项" min-width="200">
<template #default="{ row }">
<div class="item-cell">
<RouterLink
v-if="row.reservedByTaskNo"
:to="`/admin/tasks/${row.reservedByTaskNo}`"
class="item-title"
>#{{ row.inventoryItemId }}</RouterLink
>
<span v-else class="item-title">#{{ row.inventoryItemId }}</span>
<code class="item-value">{{ row.displayValue }}</code>
</div>
</template>
</el-table-column>
<el-table-column label="内部履约 SKU" min-width="160">
<template #default="{ row }">
<div class="sku-cell">
<span class="sku-title">{{ row.skuCode }}</span>
<span class="sku-subline">{{ getCredentialTypeLabel(row.credentialType) }}</span>
<span class="sku-subline sku-raw">{{ row.credentialType || '-' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="批次 / 库存组" min-width="160">
<template #default="{ row }">
<div class="info-cell">
<span class="info-subline">批次 {{ row.batchNo || '未分批' }}</span>
<span class="info-subline">库存组 {{ row.inventoryGroupCode || '未分组' }}</span>
<span class="status-copy">{{ formatInventoryStatus(row) }}</span>
<span v-if="row.invalidReason" class="status-warning"
>原因{{ row.invalidReason }}</span
>
</div>
</template>
</el-table-column>
<el-table-column label="状态 / 绑定" min-width="180">
<template #default="{ row }">
<div class="status-stack">
<AdminStatusTag :status="row.status" />
<AdminStatusTag :status="row.systemBindingStatus" />
<AdminStatusTag :status="row.userBindingStatus" />
</div>
</template>
</el-table-column>
<el-table-column label="关联任务 / 订单" min-width="160">
<template #default="{ row }">
<div class="info-cell">
<span class="link-title">{{ row.reservedByTaskNo || '-' }}</span>
<span class="link-subline">任务号</span>
<span class="link-title">{{ row.platformOrderId || '-' }}</span>
<span class="link-subline">平台订单号</span>
</div>
</template>
</el-table-column>
<el-table-column label="时间" min-width="180">
<template #default="{ row }">
<div class="info-cell">
<span class="time-line">入库 {{ formatAdminDateTime(row.createdAt) }}</span>
<span class="time-line">更新 {{ formatAdminDateTime(row.updatedAt) }}</span>
<span v-if="row.deliveredAt" class="time-line"
>发放 {{ formatAdminDateTime(row.deliveredAt) }}</span
>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="120" align="center">
<template #default="{ row }">
<div class="action-stack">
<el-button
v-if="hasAdminRole('admin') && row.status === 'reserved'"
:loading="actionLoadingId === row.inventoryItemId"
size="small"
@click="
runRowAction(
row.inventoryItemId,
() => releaseAdminInventoryItem(row.inventoryItemId),
'库存项已释放',
`确认释放库存项 #${row.inventoryItemId} 吗?释放后任务需要重新分配库存。`,
)
"
>释放</el-button
>
<el-button
v-if="hasAdminRole('admin') && row.status === 'available'"
:loading="actionLoadingId === row.inventoryItemId"
size="small"
type="danger"
plain
@click="
runRowAction(
row.inventoryItemId,
() =>
invalidateAdminInventoryItem(row.inventoryItemId, {
reason: '后台手动作废',
}),
'库存项已作废',
`确认作废库存项 #${row.inventoryItemId} 吗?作废后不会再参与自动分配。`,
)
"
>作废</el-button
>
</div>
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="pagination.total > 0"
v-model:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
layout="total, prev, pager, next"
class="pagination-center"
@current-change="loadInventoryItems"
/>
</template>
<style scoped>
@import '../../../../styles/admin-list-pages.css';
.table-toolbar {
margin-bottom: var(--space-2);
padding-top: var(--space-2);
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: 1.6;
word-break: break-word;
}
.item-cell,
.sku-cell,
.info-cell {
display: grid;
gap: 6px;
}
.item-title,
.sku-title,
.link-title {
color: var(--text-primary);
font-weight: 700;
}
.item-value {
display: inline-flex;
max-width: 100%;
width: fit-content;
padding: 6px 8px;
border-radius: var(--radius-sm);
background: rgba(15, 23, 42, 0.06);
color: #0f172a;
white-space: pre-wrap;
word-break: break-all;
}
.info-subline,
.sku-subline,
.link-subline,
.time-line,
.status-copy {
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: 1.5;
}
.sku-raw {
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.status-stack,
.action-stack {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
align-items: center;
}
.action-stack {
justify-content: center;
}
.status-warning {
color: #b54708;
font-size: var(--text-xs);
}
@media (max-width: 640px) {
.status-stack,
.action-stack {
justify-content: flex-start;
}
}
</style>
@@ -0,0 +1,21 @@
export type InventoryImportRow = {
skuCode: string
credentialType: string
batchNo: string
inventoryGroupCode: string
displayValue: string
}
export type InventoryPoolGroup = {
key: string
skuCode: string
credentialType: string
inventoryGroupCode: string
totalCount: number
availableCount: number
reservedCount: number
deliveredCount: number
invalidCount: number
latestUpdatedAt: string
batchSummary: string
}
@@ -0,0 +1,433 @@
import { computed, onMounted, ref, watch } from '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'
import type { InventoryImportRow, InventoryPoolGroup } from './types'
export function useAdminInventory() {
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()])
})
return {
// state
loading,
importing,
creating,
actionLoadingId,
errorMessage,
items,
pagination,
pageSummary,
pageOverview,
inventoryGroups,
// filter
filterSkuCode,
filterCredentialType,
filterStatus,
filterBatchNo,
filterInventoryGroupCode,
// entry
entrySkuCode,
entryCredentialType,
entryBatchNo,
entryInventoryGroupCode,
singleDisplayValue,
bulkText,
skuSuggestionsLoading,
inventorySkuSuggestions,
entrySkuSuggestions,
// actions
loadInventoryItems,
resetFilters,
submitCreate,
submitImport,
runRowAction,
useGroupAsFilter,
useGroupAsEntry,
applySkuSuggestion,
// formatters
getCredentialTypeLabel,
getCredentialTypeHint,
getSkuNamingHint,
formatInventoryStatus,
getGroupSuggestion,
// utils
hasAdminRole,
formatAdminDateTime,
adminInventoryCredentialTypeOptions,
adminInventoryStatusOptions,
}
}
@@ -1,212 +1,40 @@
<script setup lang="ts">
import { ArrowDown } from '@element-plus/icons-vue'
import { onMounted, ref } from 'vue'
import AdminPageHeader from '@/components/admin/AdminPageHeader.vue'
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
import {
isFeedbackDismissed,
showConfirm,
showError,
showPrompt,
showSuccess,
} from '@/lib/feedback'
import {
createAdminUser,
fetchAdminUsers,
resetAdminUserPassword,
updateAdminUserInventoryGroups,
updateAdminUserRole,
updateAdminUserStatus,
} from '@/services/admin'
import type { AdminPagination, AdminRole, AdminUserListItem } from '@/types/admin'
import { getAdminUserId, hasAdminRole } from '@/utils/admin-auth'
import { adminUserRoleOptions, adminUserStatusOptions } from '@/utils/admin-options'
import { formatAdminDateTime } from '@/utils/admin-time'
const loading = ref(true)
const creating = ref(false)
const actionLoadingId = ref<number | null>(null)
const errorMessage = ref('')
const username = ref('')
const role = ref('')
const status = ref('')
const createUsername = ref('')
const createPassword = ref('')
const createRole = ref<AdminRole>('operator')
const items = ref<AdminUserListItem[]>([])
const pagination = ref<AdminPagination>({ page: 1, pageSize: 20, total: 0 })
import AdminUsersFilterSection from './components/AdminUsersFilterSection.vue'
import AdminUsersTable from './components/AdminUsersTable.vue'
import { useAdminUsers } from './composables/useAdminUsers'
function isCurrentUser(userId: number) {
return getAdminUserId() === userId
}
function formatRoleLabel(role: AdminRole) {
if (role === 'admin') return '管理员'
if (role === 'support') return '客服'
return '普通运营'
}
function formatInventoryGroups(item: AdminUserListItem) {
if (item.role !== 'support') return ['不限库存组']
if (item.inventoryGroupCodes.length === 0) return ['未绑定']
return item.inventoryGroupCodes
}
async function loadUsers(page = pagination.value.page) {
if (!hasAdminRole('admin')) {
loading.value = false
return
}
loading.value = true
errorMessage.value = ''
try {
const response = await fetchAdminUsers({
page,
pageSize: pagination.value.pageSize,
username: username.value.trim(),
role: role.value.trim(),
status: status.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 submitCreate() {
if (!createUsername.value.trim() || !createPassword.value.trim()) {
showError('请填写账号和密码')
return
}
creating.value = true
try {
await createAdminUser({
username: createUsername.value.trim(),
password: createPassword.value.trim(),
role: createRole.value,
status: 'active',
})
showSuccess('后台用户已创建')
createUsername.value = ''
createPassword.value = ''
createRole.value = 'operator'
await loadUsers(1)
} catch (error) {
showError(error instanceof Error ? error.message : '创建后台用户失败')
} finally {
creating.value = false
}
}
async function updateRole(item: AdminUserListItem, nextRole: AdminRole) {
if (item.role === nextRole) return
try {
await showConfirm(
`确认将 ${item.username} 调整为${formatRoleLabel(nextRole)}吗?`,
'确认操作',
{ type: 'warning', confirmButtonText: '继续执行', cancelButtonText: '取消' },
)
} catch {
return
}
actionLoadingId.value = item.userId
try {
await updateAdminUserRole(item.userId, { role: nextRole })
showSuccess('用户角色已更新')
await loadUsers()
} catch (error) {
showError(error instanceof Error ? error.message : '更新用户角色失败')
} finally {
actionLoadingId.value = null
}
}
async function toggleStatus(item: AdminUserListItem) {
const nextStatus = item.status === 'active' ? 'disabled' : 'active'
try {
await showConfirm(
`确认将 ${item.username}${nextStatus === 'active' ? '启用' : '停用'}吗?`,
'确认操作',
{
type: nextStatus === 'active' ? 'info' : 'warning',
confirmButtonText: '继续执行',
cancelButtonText: '取消',
},
)
} catch {
return
}
actionLoadingId.value = item.userId
try {
await updateAdminUserStatus(item.userId, { status: nextStatus })
showSuccess('用户状态已更新')
await loadUsers()
} catch (error) {
showError(error instanceof Error ? error.message : '更新用户状态失败')
} finally {
actionLoadingId.value = null
}
}
async function promptResetPassword(item: AdminUserListItem) {
try {
const result = await showPrompt(`请输入 ${item.username} 的新密码`, '重置密码', {
inputType: 'password',
inputPlaceholder: '至少 8 位',
confirmButtonText: '提交',
cancelButtonText: '取消',
inputValidator: (value) => value.trim().length >= 8 || '密码至少 8 位',
})
actionLoadingId.value = item.userId
await resetAdminUserPassword(item.userId, { password: result.value.trim() })
showSuccess('用户密码已重置')
await loadUsers()
} catch (error) {
if (isFeedbackDismissed(error)) return
showError(error instanceof Error ? error.message : '重置密码失败')
} finally {
actionLoadingId.value = null
}
}
async function promptUpdateInventoryGroups(item: AdminUserListItem) {
if (item.role !== 'support') {
showError('仅客服账号需要绑定库存组')
return
}
try {
const result = await showPrompt(
`请输入 ${item.username} 可用的库存组,多个组用英文逗号分隔`,
'配置库存组',
{
inputType: 'text',
inputValue: item.inventoryGroupCodes.join(','),
inputPlaceholder: '例如 A组,B组,售后组',
confirmButtonText: '保存',
cancelButtonText: '取消',
},
)
const inventoryGroupCodes = String(result.value || '')
.split(',')
.map((v) => v.trim())
.filter(Boolean)
actionLoadingId.value = item.userId
await updateAdminUserInventoryGroups(item.userId, { inventoryGroupCodes })
showSuccess('用户库存组已更新')
await loadUsers()
} catch (error) {
if (isFeedbackDismissed(error)) return
showError(error instanceof Error ? error.message : '更新库存组失败')
} finally {
actionLoadingId.value = null
}
}
onMounted(loadUsers)
const {
loading,
creating,
actionLoadingId,
errorMessage,
items,
pagination,
// filter
username,
role,
status,
// create
createUsername,
createPassword,
createRole,
// actions
loadUsers,
submitCreate,
updateRole,
toggleStatus,
promptResetPassword,
promptUpdateInventoryGroups,
// formatters
isCurrentUser,
formatRoleLabel,
formatInventoryGroups,
// utils
hasAdminRole,
formatAdminDateTime,
} = useAdminUsers()
</script>
<template>
@@ -219,66 +47,17 @@ onMounted(loadUsers)
<el-result v-if="!hasAdminRole('admin')" icon="warning" title="仅管理员可以访问用户管理。" />
<template v-else>
<section class="users-tools">
<div class="filter-row">
<el-input
v-model="username"
class="filter-control filter-control--username"
placeholder="账号筛选"
clearable
@keyup.enter="loadUsers(1)"
<AdminUsersFilterSection
v-model:username="username"
v-model:role="role"
v-model:status="status"
v-model:create-username="createUsername"
v-model:create-password="createPassword"
v-model:create-role="createRole"
:creating="creating"
:load-users="loadUsers"
:submit-create="submitCreate"
/>
<el-select
v-model="role"
class="filter-control filter-control--role"
placeholder="角色"
clearable
>
<el-option
v-for="opt in adminUserRoleOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-select
v-model="status"
class="filter-control filter-control--status"
placeholder="状态"
clearable
>
<el-option
v-for="opt in adminUserStatusOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-button round type="primary" @click="loadUsers(1)">查询列表</el-button>
</div>
<div class="create-row">
<el-input
v-model="createUsername"
class="filter-control filter-control--username"
placeholder="新账号"
/>
<el-input
v-model="createPassword"
class="filter-control filter-control--password"
type="password"
show-password
placeholder="新密码,至少 8 位"
/>
<el-select v-model="createRole" class="filter-control filter-control--role">
<el-option value="operator" label="普通运营" />
<el-option value="support" label="客服" />
<el-option value="admin" label="管理员" />
</el-select>
<el-button round :loading="creating" type="primary" @click="submitCreate"
>新增用户</el-button
>
</div>
</section>
<el-alert
v-if="errorMessage"
@@ -289,227 +68,25 @@ onMounted(loadUsers)
class="mt-4"
/>
<el-card shadow="never" class="section-card">
<div class="users-table-toolbar table-toolbar">
<strong>用户列表</strong>
<span> {{ pagination.total }} 个账号</span>
</div>
<el-table
:data="items"
stripe
size="small"
v-loading="loading"
element-loading-text="用户列表加载中"
class="users-table data-table w-full"
>
<el-table-column prop="userId" label="ID" width="52" align="center" />
<el-table-column prop="username" label="账号" width="112" show-overflow-tooltip />
<el-table-column label="角色" width="82">
<template #default="{ row }">{{ formatRoleLabel(row.role) }}</template>
</el-table-column>
<el-table-column label="库存组" width="112">
<template #default="{ row }">
<div class="inventory-tags">
<el-tag
v-for="(code, i) in formatInventoryGroups(row)"
:key="i"
size="small"
:type="code === '未绑定' || code === '不限库存组' ? 'info' : undefined"
>{{ code }}</el-tag
>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="128">
<template #default="{ row }"><AdminStatusTag :status="row.status" /></template>
</el-table-column>
<el-table-column label="创建时间" width="154">
<template #default="{ row }"
><span class="date-cell">{{ formatAdminDateTime(row.createdAt) }}</span></template
>
</el-table-column>
<el-table-column label="更新时间" width="154">
<template #default="{ row }"
><span class="date-cell">{{ formatAdminDateTime(row.updatedAt) }}</span></template
>
</el-table-column>
<el-table-column label="操作" min-width="356">
<template #default="{ row }">
<div class="action-stack">
<el-dropdown
trigger="click"
:disabled="isCurrentUser(row.userId)"
@command="(cmd: string) => updateRole(row, cmd as AdminRole)"
>
<el-button
:loading="actionLoadingId === row.userId"
size="small"
:disabled="isCurrentUser(row.userId)"
>
改角色<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="admin" :disabled="row.role === 'admin'"
>设为管理员</el-dropdown-item
>
<el-dropdown-item command="operator" :disabled="row.role === 'operator'"
>设为运营</el-dropdown-item
>
<el-dropdown-item command="support" :disabled="row.role === 'support'"
>设为客服</el-dropdown-item
>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button
:loading="actionLoadingId === row.userId"
size="small"
:disabled="isCurrentUser(row.userId)"
@click="toggleStatus(row)"
>{{ row.status === 'active' ? '停用' : '启用' }}</el-button
>
<el-button
:loading="actionLoadingId === row.userId"
size="small"
:disabled="row.role !== 'support'"
@click="promptUpdateInventoryGroups(row)"
>配库存组</el-button
>
<el-button
:loading="actionLoadingId === row.userId"
size="small"
type="primary"
@click="promptResetPassword(row)"
>重置密码</el-button
>
<el-tag v-if="isCurrentUser(row.userId)" size="small" type="info">当前账号</el-tag>
</div>
</template>
</el-table-column>
<template #empty><el-empty :image-size="60" description="暂无用户" /></template>
</el-table>
<el-pagination
v-if="pagination.total > 0"
v-model:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
layout="total, prev, pager, next"
class="pagination-center"
@current-change="loadUsers"
<AdminUsersTable
:loading="loading"
:items="items"
:pagination="pagination"
:action-loading-id="actionLoadingId"
:load-users="loadUsers"
:is-current-user="isCurrentUser"
:update-role="updateRole"
:toggle-status="toggleStatus"
:prompt-reset-password="promptResetPassword"
:prompt-update-inventory-groups="promptUpdateInventoryGroups"
:format-role-label="formatRoleLabel"
:format-inventory-groups="formatInventoryGroups"
:format-admin-date-time="formatAdminDateTime"
/>
</el-card>
</template>
</div>
</template>
<style scoped>
@import '../../../styles/admin-list-pages.css';
.users-tools {
display: grid;
gap: var(--space-3);
margin-top: var(--space-4);
}
.filter-row,
.create-row {
display: flex;
gap: var(--space-3);
align-items: center;
flex-wrap: wrap;
}
.create-row {
padding-top: var(--space-3);
border-top: 1px solid rgba(86, 108, 138, 0.12);
}
.filter-control {
min-width: 0;
}
.filter-control--username {
width: 150px;
}
.filter-control--role,
.filter-control--status {
width: 128px;
}
.filter-control--password {
width: 210px;
}
.users-table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-2);
}
.users-table-toolbar strong {
color: var(--text-primary);
font-size: var(--text-base);
}
.users-table-toolbar span {
color: var(--text-secondary);
font-size: var(--text-xs);
font-weight: 700;
}
.users-table {
background: rgba(255, 255, 255, 0.36);
}
.users-table :deep(td.el-table__cell) {
height: 44px;
background: rgba(255, 255, 255, 0.46);
}
.users-table :deep(.el-table__row--striped td.el-table__cell) {
background: rgba(255, 255, 255, 0.62);
}
.users-table :deep(.el-table__body tr:hover > td.el-table__cell) {
background: var(--el-table-row-hover-bg-color);
}
.inventory-tags {
display: flex;
gap: 4px;
align-items: center;
white-space: nowrap;
}
.inventory-tags :deep(.el-tag) {
flex: 0 0 auto;
}
.date-cell {
display: inline-block;
white-space: nowrap;
font-size: var(--text-xs);
}
.action-stack {
display: flex;
flex-wrap: nowrap;
gap: 6px;
align-items: center;
white-space: nowrap;
}
.action-stack :deep(.el-button) {
flex: 0 0 auto;
margin-left: 0;
padding: 5px 8px;
}
.action-stack :deep(.el-tag) {
flex: 0 0 auto;
padding: 0 7px;
}
.action-stack :deep(.el-icon--right) {
margin-left: 4px;
}
@media (max-width: 640px) {
.filter-control,
.filter-control--username,
.filter-control--role,
.filter-control--status,
.filter-control--password {
width: 100%;
}
.filter-row :deep(.el-button),
.create-row :deep(.el-button) {
width: 100%;
}
}
@import '../../styles/admin-list-pages.css';
</style>
@@ -0,0 +1,127 @@
<script setup lang="ts">
import type { AdminRole } from '@/types/admin'
import { adminUserRoleOptions, adminUserStatusOptions } from '@/utils/admin-options'
const username = defineModel<string>('username', { required: true })
const role = defineModel<string>('role', { required: true })
const status = defineModel<string>('status', { required: true })
const createUsername = defineModel<string>('createUsername', { required: true })
const createPassword = defineModel<string>('createPassword', { required: true })
const createRole = defineModel<AdminRole>('createRole', { required: true })
defineProps<{
creating: boolean
loadUsers: (page?: number) => Promise<void>
submitCreate: () => Promise<void>
}>()
</script>
<template>
<section class="users-tools">
<div class="filter-row">
<el-input
v-model="username"
class="filter-control filter-control--username"
placeholder="账号筛选"
clearable
@keyup.enter="loadUsers(1)"
/>
<el-select
v-model="role"
class="filter-control filter-control--role"
placeholder="角色"
clearable
>
<el-option
v-for="opt in adminUserRoleOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-select
v-model="status"
class="filter-control filter-control--status"
placeholder="状态"
clearable
>
<el-option
v-for="opt in adminUserStatusOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-button round type="primary" @click="loadUsers(1)">查询列表</el-button>
</div>
<div class="create-row">
<el-input
v-model="createUsername"
class="filter-control filter-control--username"
placeholder="新账号"
/>
<el-input
v-model="createPassword"
class="filter-control filter-control--password"
type="password"
show-password
placeholder="新密码,至少 8 位"
/>
<el-select v-model="createRole" class="filter-control filter-control--role">
<el-option value="operator" label="普通运营" />
<el-option value="support" label="客服" />
<el-option value="admin" label="管理员" />
</el-select>
<el-button round :loading="creating" type="primary" @click="submitCreate"
>新增用户</el-button
>
</div>
</section>
</template>
<style scoped>
.users-tools {
display: grid;
gap: var(--space-3);
margin-top: var(--space-4);
}
.filter-row,
.create-row {
display: flex;
gap: var(--space-3);
align-items: center;
flex-wrap: wrap;
}
.create-row {
padding-top: var(--space-3);
border-top: 1px solid rgba(86, 108, 138, 0.12);
}
.filter-control {
min-width: 0;
}
.filter-control--username {
width: 150px;
}
.filter-control--role,
.filter-control--status {
width: 128px;
}
.filter-control--password {
width: 210px;
}
@media (max-width: 640px) {
.filter-control,
.filter-control--username,
.filter-control--role,
.filter-control--status,
.filter-control--password {
width: 100%;
}
.filter-row :deep(.el-button),
.create-row :deep(.el-button) {
width: 100%;
}
}
</style>
@@ -0,0 +1,199 @@
<script setup lang="ts">
import { ArrowDown } from '@element-plus/icons-vue'
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
import type { AdminRole, AdminUserListItem } from '@/types/admin'
defineProps<{
loading: boolean
items: AdminUserListItem[]
pagination: { page: number; pageSize: number; total: number }
actionLoadingId: number | null
loadUsers: (page?: number) => Promise<void>
isCurrentUser: (userId: number) => boolean
updateRole: (item: AdminUserListItem, nextRole: AdminRole) => Promise<void>
toggleStatus: (item: AdminUserListItem) => Promise<void>
promptResetPassword: (item: AdminUserListItem) => Promise<void>
promptUpdateInventoryGroups: (item: AdminUserListItem) => Promise<void>
formatRoleLabel: (role: AdminRole) => string
formatInventoryGroups: (item: AdminUserListItem) => string[]
formatAdminDateTime: (value: string) => string
}>()
</script>
<template>
<el-card shadow="never" class="section-card">
<div class="users-table-toolbar table-toolbar">
<strong>用户列表</strong>
<span> {{ pagination.total }} 个账号</span>
</div>
<el-table
:data="items"
stripe
size="small"
v-loading="loading"
element-loading-text="用户列表加载中"
class="users-table data-table w-full"
>
<el-table-column prop="userId" label="ID" width="52" align="center" />
<el-table-column prop="username" label="账号" width="112" show-overflow-tooltip />
<el-table-column label="角色" width="82">
<template #default="{ row }">{{ formatRoleLabel(row.role) }}</template>
</el-table-column>
<el-table-column label="库存组" width="112">
<template #default="{ row }">
<div class="inventory-tags">
<el-tag
v-for="(code, i) in formatInventoryGroups(row)"
:key="i"
size="small"
:type="code === '未绑定' || code === '不限库存组' ? 'info' : undefined"
>{{ code }}</el-tag
>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="128">
<template #default="{ row }"><AdminStatusTag :status="row.status" /></template>
</el-table-column>
<el-table-column label="创建时间" width="154">
<template #default="{ row }"
><span class="date-cell">{{ formatAdminDateTime(row.createdAt) }}</span></template
>
</el-table-column>
<el-table-column label="更新时间" width="154">
<template #default="{ row }"
><span class="date-cell">{{ formatAdminDateTime(row.updatedAt) }}</span></template
>
</el-table-column>
<el-table-column label="操作" min-width="356">
<template #default="{ row }">
<div class="action-stack">
<el-dropdown
trigger="click"
:disabled="isCurrentUser(row.userId)"
@command="(cmd: string) => updateRole(row, cmd as AdminRole)"
>
<el-button
:loading="actionLoadingId === row.userId"
size="small"
:disabled="isCurrentUser(row.userId)"
>
改角色<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="admin" :disabled="row.role === 'admin'"
>设为管理员</el-dropdown-item
>
<el-dropdown-item command="operator" :disabled="row.role === 'operator'"
>设为运营</el-dropdown-item
>
<el-dropdown-item command="support" :disabled="row.role === 'support'"
>设为客服</el-dropdown-item
>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button
:loading="actionLoadingId === row.userId"
size="small"
:disabled="isCurrentUser(row.userId)"
@click="toggleStatus(row)"
>{{ row.status === 'active' ? '停用' : '启用' }}</el-button
>
<el-button
:loading="actionLoadingId === row.userId"
size="small"
:disabled="row.role !== 'support'"
@click="promptUpdateInventoryGroups(row)"
>配库存组</el-button
>
<el-button
:loading="actionLoadingId === row.userId"
size="small"
type="primary"
@click="promptResetPassword(row)"
>重置密码</el-button
>
<el-tag v-if="isCurrentUser(row.userId)" size="small" type="info">当前账号</el-tag>
</div>
</template>
</el-table-column>
<template #empty><el-empty :image-size="60" description="暂无用户" /></template>
</el-table>
<el-pagination
v-if="pagination.total > 0"
v-model:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
layout="total, prev, pager, next"
class="pagination-center"
@current-change="loadUsers"
/>
</el-card>
</template>
<style scoped>
.users-table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-2);
}
.users-table-toolbar strong {
color: var(--text-primary);
font-size: var(--text-base);
}
.users-table-toolbar span {
color: var(--text-secondary);
font-size: var(--text-xs);
font-weight: 700;
}
.users-table {
background: rgba(255, 255, 255, 0.36);
}
.users-table :deep(td.el-table__cell) {
height: 44px;
background: rgba(255, 255, 255, 0.46);
}
.users-table :deep(.el-table__row--striped td.el-table__cell) {
background: rgba(255, 255, 255, 0.62);
}
.users-table :deep(.el-table__body tr:hover > td.el-table__cell) {
background: var(--el-table-row-hover-bg-color);
}
.inventory-tags {
display: flex;
gap: 4px;
align-items: center;
white-space: nowrap;
}
.inventory-tags :deep(.el-tag) {
flex: 0 0 auto;
}
.date-cell {
display: inline-block;
white-space: nowrap;
font-size: var(--text-xs);
}
.action-stack {
display: flex;
flex-wrap: nowrap;
gap: 6px;
align-items: center;
white-space: nowrap;
}
.action-stack :deep(.el-button) {
flex: 0 0 auto;
margin-left: 0;
padding: 5px 8px;
}
.action-stack :deep(.el-tag) {
flex: 0 0 auto;
padding: 0 7px;
}
.action-stack :deep(.el-icon--right) {
margin-left: 4px;
}
</style>
@@ -0,0 +1,241 @@
import { onMounted, ref } from 'vue'
import {
isFeedbackDismissed,
showConfirm,
showError,
showPrompt,
showSuccess,
} from '@/lib/feedback'
import {
createAdminUser,
fetchAdminUsers,
resetAdminUserPassword,
updateAdminUserInventoryGroups,
updateAdminUserRole,
updateAdminUserStatus,
} from '@/services/admin'
import type { AdminPagination, AdminRole, AdminUserListItem } from '@/types/admin'
import { getAdminUserId, hasAdminRole } from '@/utils/admin-auth'
import { adminUserRoleOptions, adminUserStatusOptions } from '@/utils/admin-options'
import { formatAdminDateTime } from '@/utils/admin-time'
export function useAdminUsers() {
const loading = ref(true)
const creating = ref(false)
const actionLoadingId = ref<number | null>(null)
const errorMessage = ref('')
const username = ref('')
const role = ref('')
const status = ref('')
const createUsername = ref('')
const createPassword = ref('')
const createRole = ref<AdminRole>('operator')
const items = ref<AdminUserListItem[]>([])
const pagination = ref<AdminPagination>({ page: 1, pageSize: 20, total: 0 })
function isCurrentUser(userId: number) {
return getAdminUserId() === userId
}
function formatRoleLabel(role: AdminRole) {
if (role === 'admin') return '管理员'
if (role === 'support') return '客服'
return '普通运营'
}
function formatInventoryGroups(item: AdminUserListItem) {
if (item.role !== 'support') return ['不限库存组']
if (item.inventoryGroupCodes.length === 0) return ['未绑定']
return item.inventoryGroupCodes
}
async function loadUsers(page = pagination.value.page) {
if (!hasAdminRole('admin')) {
loading.value = false
return
}
loading.value = true
errorMessage.value = ''
try {
const response = await fetchAdminUsers({
page,
pageSize: pagination.value.pageSize,
username: username.value.trim(),
role: role.value.trim(),
status: status.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 submitCreate() {
if (!createUsername.value.trim() || !createPassword.value.trim()) {
showError('请填写账号和密码')
return
}
creating.value = true
try {
await createAdminUser({
username: createUsername.value.trim(),
password: createPassword.value.trim(),
role: createRole.value,
status: 'active',
})
showSuccess('后台用户已创建')
createUsername.value = ''
createPassword.value = ''
createRole.value = 'operator'
await loadUsers(1)
} catch (error) {
showError(error instanceof Error ? error.message : '创建后台用户失败')
} finally {
creating.value = false
}
}
async function updateRole(item: AdminUserListItem, nextRole: AdminRole) {
if (item.role === nextRole) return
try {
await showConfirm(
`确认将 ${item.username} 调整为${formatRoleLabel(nextRole)}吗?`,
'确认操作',
{ type: 'warning', confirmButtonText: '继续执行', cancelButtonText: '取消' },
)
} catch {
return
}
actionLoadingId.value = item.userId
try {
await updateAdminUserRole(item.userId, { role: nextRole })
showSuccess('用户角色已更新')
await loadUsers()
} catch (error) {
showError(error instanceof Error ? error.message : '更新用户角色失败')
} finally {
actionLoadingId.value = null
}
}
async function toggleStatus(item: AdminUserListItem) {
const nextStatus = item.status === 'active' ? 'disabled' : 'active'
try {
await showConfirm(
`确认将 ${item.username}${nextStatus === 'active' ? '启用' : '停用'}吗?`,
'确认操作',
{
type: nextStatus === 'active' ? 'info' : 'warning',
confirmButtonText: '继续执行',
cancelButtonText: '取消',
},
)
} catch {
return
}
actionLoadingId.value = item.userId
try {
await updateAdminUserStatus(item.userId, { status: nextStatus })
showSuccess('用户状态已更新')
await loadUsers()
} catch (error) {
showError(error instanceof Error ? error.message : '更新用户状态失败')
} finally {
actionLoadingId.value = null
}
}
async function promptResetPassword(item: AdminUserListItem) {
try {
const result = await showPrompt(`请输入 ${item.username} 的新密码`, '重置密码', {
inputType: 'password',
inputPlaceholder: '至少 8 位',
confirmButtonText: '提交',
cancelButtonText: '取消',
inputValidator: (value) => value.trim().length >= 8 || '密码至少 8 位',
})
actionLoadingId.value = item.userId
await resetAdminUserPassword(item.userId, { password: result.value.trim() })
showSuccess('用户密码已重置')
await loadUsers()
} catch (error) {
if (isFeedbackDismissed(error)) return
showError(error instanceof Error ? error.message : '重置密码失败')
} finally {
actionLoadingId.value = null
}
}
async function promptUpdateInventoryGroups(item: AdminUserListItem) {
if (item.role !== 'support') {
showError('仅客服账号需要绑定库存组')
return
}
try {
const result = await showPrompt(
`请输入 ${item.username} 可用的库存组,多个组用英文逗号分隔`,
'配置库存组',
{
inputType: 'text',
inputValue: item.inventoryGroupCodes.join(','),
inputPlaceholder: '例如 A组,B组,售后组',
confirmButtonText: '保存',
cancelButtonText: '取消',
},
)
const inventoryGroupCodes = String(result.value || '')
.split(',')
.map((v) => v.trim())
.filter(Boolean)
actionLoadingId.value = item.userId
await updateAdminUserInventoryGroups(item.userId, { inventoryGroupCodes })
showSuccess('用户库存组已更新')
await loadUsers()
} catch (error) {
if (isFeedbackDismissed(error)) return
showError(error instanceof Error ? error.message : '更新库存组失败')
} finally {
actionLoadingId.value = null
}
}
onMounted(loadUsers)
return {
// state
loading,
creating,
actionLoadingId,
errorMessage,
items,
pagination,
// filter
username,
role,
status,
// create
createUsername,
createPassword,
createRole,
// actions
loadUsers,
submitCreate,
updateRole,
toggleStatus,
promptResetPassword,
promptUpdateInventoryGroups,
// formatters
isCurrentUser,
formatRoleLabel,
formatInventoryGroups,
// utils
hasAdminRole,
formatAdminDateTime,
adminUserRoleOptions,
adminUserStatusOptions,
}
}