diff --git a/apps/frontend/src/views/admin/inventory/AdminInventoryView.css b/apps/frontend/src/views/admin/inventory/AdminInventoryView.css deleted file mode 100644 index b240885a..00000000 --- a/apps/frontend/src/views/admin/inventory/AdminInventoryView.css +++ /dev/null @@ -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; - } -} diff --git a/apps/frontend/src/views/admin/inventory/AdminInventoryView.vue b/apps/frontend/src/views/admin/inventory/AdminInventoryView.vue index 68f0511c..8d8d7de6 100644 --- a/apps/frontend/src/views/admin/inventory/AdminInventoryView.vue +++ b/apps/frontend/src/views/admin/inventory/AdminInventoryView.vue @@ -1,407 +1,58 @@ - - -
- - - - - - - - - -
-
- 查询列表 - 清空筛选 -
-
+ - - - -
-
- - - -
- - - - - -
- -
- {{ getSkuNamingHint() }} - 正在加载同类型内部 SKU... -
- -
- -
- -
-
- 当前默认匹配 - {{ getCredentialTypeLabel(entryCredentialType) }} -
-
- 库存组 - {{ entryInventoryGroupCode || '未分组,默认全局可用' }} -
-
- 说明 - {{ getCredentialTypeHint(entryCredentialType) }} -
-
- -
- - 单条新增 -
- - -

- 示例:sjz_hdl_test01|tencent_code|sjz-20260412|A组|DJQFf7HgXXXXXXXXXX -

- -
- 批量导入 -
-
+ { - + diff --git a/apps/frontend/src/views/admin/inventory/components/AdminInventoryEntryCard.vue b/apps/frontend/src/views/admin/inventory/components/AdminInventoryEntryCard.vue new file mode 100644 index 00000000..35e625ba --- /dev/null +++ b/apps/frontend/src/views/admin/inventory/components/AdminInventoryEntryCard.vue @@ -0,0 +1,302 @@ + + + + + diff --git a/apps/frontend/src/views/admin/inventory/components/AdminInventoryFilterCard.vue b/apps/frontend/src/views/admin/inventory/components/AdminInventoryFilterCard.vue new file mode 100644 index 00000000..194ac0da --- /dev/null +++ b/apps/frontend/src/views/admin/inventory/components/AdminInventoryFilterCard.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/apps/frontend/src/views/admin/inventory/components/AdminInventoryPoolGrid.vue b/apps/frontend/src/views/admin/inventory/components/AdminInventoryPoolGrid.vue new file mode 100644 index 00000000..d92fa85b --- /dev/null +++ b/apps/frontend/src/views/admin/inventory/components/AdminInventoryPoolGrid.vue @@ -0,0 +1,234 @@ + + + + + diff --git a/apps/frontend/src/views/admin/inventory/components/AdminInventoryTable.vue b/apps/frontend/src/views/admin/inventory/components/AdminInventoryTable.vue new file mode 100644 index 00000000..eb5ab0cb --- /dev/null +++ b/apps/frontend/src/views/admin/inventory/components/AdminInventoryTable.vue @@ -0,0 +1,222 @@ + + + + + diff --git a/apps/frontend/src/views/admin/inventory/composables/types.ts b/apps/frontend/src/views/admin/inventory/composables/types.ts new file mode 100644 index 00000000..6f0092e1 --- /dev/null +++ b/apps/frontend/src/views/admin/inventory/composables/types.ts @@ -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 +} diff --git a/apps/frontend/src/views/admin/inventory/composables/useAdminInventory.ts b/apps/frontend/src/views/admin/inventory/composables/useAdminInventory.ts new file mode 100644 index 00000000..9c85aa54 --- /dev/null +++ b/apps/frontend/src/views/admin/inventory/composables/useAdminInventory.ts @@ -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(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([]) + + const items = ref([]) + const pagination = ref({ + 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(() => { + 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 + } + >() + + 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(), + } + + 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, + 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, + } +} diff --git a/apps/frontend/src/views/admin/users/AdminUsersView.vue b/apps/frontend/src/views/admin/users/AdminUsersView.vue index f3bfcc40..9890103e 100644 --- a/apps/frontend/src/views/admin/users/AdminUsersView.vue +++ b/apps/frontend/src/views/admin/users/AdminUsersView.vue @@ -1,212 +1,40 @@ diff --git a/apps/frontend/src/views/admin/users/components/AdminUsersFilterSection.vue b/apps/frontend/src/views/admin/users/components/AdminUsersFilterSection.vue new file mode 100644 index 00000000..287578e5 --- /dev/null +++ b/apps/frontend/src/views/admin/users/components/AdminUsersFilterSection.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/apps/frontend/src/views/admin/users/components/AdminUsersTable.vue b/apps/frontend/src/views/admin/users/components/AdminUsersTable.vue new file mode 100644 index 00000000..ccfd079a --- /dev/null +++ b/apps/frontend/src/views/admin/users/components/AdminUsersTable.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/apps/frontend/src/views/admin/users/composables/useAdminUsers.ts b/apps/frontend/src/views/admin/users/composables/useAdminUsers.ts new file mode 100644 index 00000000..c08944e1 --- /dev/null +++ b/apps/frontend/src/views/admin/users/composables/useAdminUsers.ts @@ -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(null) + const errorMessage = ref('') + const username = ref('') + const role = ref('') + const status = ref('') + const createUsername = ref('') + const createPassword = ref('') + const createRole = ref('operator') + const items = ref([]) + const pagination = ref({ 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, + } +}