Files
admin-web/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue
T
2026-10-20 15:48:27 +08:00

436 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css'
import { computed, onMounted, ref, watch } from 'vue'
import { FileSpreadsheet, FileUp } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import {
downloadPlatformPriceBookSource,
listPlatformPriceBooks,
uploadPlatformPriceBook,
voidPlatformPriceBook,
type PlatformPriceBookVersion,
type PriceBookStatus,
} from '../api/platformPriceBooks'
type PriceBookFilters = QueryModel & {
customerId: string
status: PriceBookStatus | ''
}
const access = useAccessPreviewStore()
const router = useRouter()
const rows = ref<PlatformPriceBookVersion[]>([])
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const uploadOpen = ref(false)
const uploadFile = ref<File | null>(null)
const uploadFileError = ref('')
const uploadCustomerId = ref('0')
const uploadInput = ref<HTMLInputElement>()
const uploading = ref(false)
const downloadingIds = ref(new Set<string>())
const voidingIds = ref(new Set<string>())
const customerOptions = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const filters = ref<PriceBookFilters>({ customerId: '0', status: '' })
const appliedFilters = ref<PriceBookFilters>({ ...filters.value })
let loadSequence = 0
const queryFields = computed<QueryField[]>(() => [
{
key: 'customerId',
type: 'select',
width: 260,
label: '报价范围',
placeholder: '请选择报价范围',
props: { 'aria-label': '报价范围', filterable: true, loading: customersLoading.value },
options: [
{ label: '平台公共报价', value: '0' },
...customerOptions.value.map((customer) => ({ label: `客户专属 · ${customer.tenantName || '未命名客户'}`, value: String(customer.id) })),
],
},
{
key: 'status',
type: 'select',
width: 190,
label: '版本状态',
placeholder: '全部版本状态',
props: { 'aria-label': '版本状态' },
options: [
{ label: '待确认', value: 'PENDING_CONFIRMATION' },
{ label: '生效中', value: 'ACTIVE' },
{ label: '已替换', value: 'SUPERSEDED' },
{ label: '已作废', value: 'VOIDED' },
{ label: '解析失败', value: 'FAILED' },
],
},
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
{ key: 'scope', label: '报价范围', minWidth: 190, slotName: 'scope' },
{ key: 'version', label: '价格表版本', minWidth: 180, slotName: 'version' },
{ key: 'originalFileName', prop: 'originalFileName', label: '原始文件', minWidth: 250, showOverflowTooltip: true, defaultVisible: false },
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
{ key: 'contents', label: '解析结果', minWidth: 155, slotName: 'contents' },
{ key: 'issues', label: '校验结果', minWidth: 150, slotName: 'issues' },
{ key: 'createdAt', label: '上传时间', minWidth: 165, formatter: (row: PlatformPriceBookVersion) => formatDateTime(row.createdAt) },
{ key: 'effectivePeriod', label: '实际生效区间', minWidth: 290, formatter: (row: PlatformPriceBookVersion) => formatEffectivePeriod(row) },
]
const canEdit = computed(() => access.can('firstleg:fba-pricing:rate:edit'))
const pagedRows = computed(() => rows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
const activeCount = computed(() => rows.value.filter((row) => row.status === 'ACTIVE').length)
const pendingCount = computed(() => rows.value.filter((row) => row.status === 'PENDING_CONFIRMATION').length)
const loadErrorMessage = computed(() => loadError.value?.message || '')
function formatDateTime(value: string | null | undefined) {
return value ? value.replace('T', ' ').slice(0, 16) : '-'
}
function formatEffectivePeriod(version: PlatformPriceBookVersion) {
if (!version.publishedAt) return '尚未生效'
const start = formatDateTime(version.publishedAt)
return version.supersededAt ? `${start} 至 ${formatDateTime(version.supersededAt)}` : `${start} 起`
}
function priceBookScopeLabel(customerId: string | null | undefined) {
if (!customerId || customerId === '0') return '平台公共报价'
const customer = customerOptions.value.find((item) => String(item.id) === String(customerId))
return customer ? `客户专属 · ${customer.tenantName || '未命名客户'}` : '客户专属报价'
}
async function loadCustomerOptions() {
customersLoading.value = true
try {
const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })
customerOptions.value = result.records.filter((customer) => customer.status === 'ACTIVE')
} catch {
customerOptions.value = []
ElMessage.warning('客户列表加载失败,仍可维护平台公共报价')
} finally {
customersLoading.value = false
}
}
function priceBookStatusMeta(status: PriceBookStatus) {
const values = {
PENDING_CONFIRMATION: { label: '待确认', type: 'warning' as const },
ACTIVE: { label: '生效中', type: 'success' as const },
SUPERSEDED: { label: '已替换', type: 'info' as const },
VOIDED: { label: '已作废', type: 'info' as const },
FAILED: { label: '解析失败', type: 'danger' as const },
}
return values[status]
}
function apiErrorMessage(error: unknown, fallback: string) {
const details = getApiErrorDetails(error, fallback)
return details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message
}
function safeFileName(value: string) {
const safe = value.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').trim()
return safe || '船期价格表.xlsx'
}
function saveBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
try {
anchor.href = url
anchor.download = safeFileName(fileName)
document.body.append(anchor)
anchor.click()
} finally {
anchor.remove()
window.setTimeout(() => URL.revokeObjectURL(url), 0)
}
}
async function loadPriceBooks() {
const sequence = ++loadSequence
loading.value = true
loadError.value = null
try {
const result = await listPlatformPriceBooks({ customerId: appliedFilters.value.customerId, status: appliedFilters.value.status })
if (sequence !== loadSequence) return
rows.value = result
} catch (error) {
if (sequence !== loadSequence) return
rows.value = []
loadError.value = getApiErrorDetails(error, '船期价格表版本加载失败,请稍后重试')
} finally {
if (sequence === loadSequence) loading.value = false
}
}
async function applyQuery(model: QueryModel) {
appliedFilters.value = {
customerId: String(model.customerId || '0'),
status: (model.status || '') as PriceBookStatus | '',
}
page.value = 1
await loadPriceBooks()
}
function resetUpload() {
uploadFile.value = null
uploadFileError.value = ''
if (uploadInput.value) uploadInput.value.value = ''
}
function openUpload() {
resetUpload()
uploadCustomerId.value = appliedFilters.value.customerId || '0'
uploadOpen.value = true
}
function selectUploadFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0] || null
uploadFile.value = null
uploadFileError.value = ''
if (!file) return
if (!file.name.toLowerCase().endsWith('.xlsx')) {
uploadFileError.value = '仅支持 XLSX 格式的船期价格表。'
input.value = ''
return
}
if (file.size > 10 * 1024 * 1024) {
uploadFileError.value = '价格表文件不能超过 10MB。'
input.value = ''
return
}
uploadFile.value = file
}
function openDetail(row: PlatformPriceBookVersion) {
void router.push({ name: 'fba-carrier-rate-detail', params: { versionId: row.id } })
}
async function submitUpload() {
const file = uploadFile.value
if (!file) {
ElMessage.error('请选择需要上传的 XLSX 价格表。')
return
}
uploading.value = true
try {
const result = await uploadPlatformPriceBook(file, uploadCustomerId.value)
uploadOpen.value = false
resetUpload()
await loadPriceBooks()
ElMessage.success('价格表已解析,请在详情页核对结果并确认仓库映射。')
await router.push({ name: 'fba-carrier-rate-detail', params: { versionId: result.version.id } })
} catch (error) {
ElMessage.error(apiErrorMessage(error, '价格表上传或解析失败,请检查文件后重试'))
await loadPriceBooks()
} finally {
uploading.value = false
}
}
async function downloadSource(row: PlatformPriceBookVersion) {
const next = new Set(downloadingIds.value)
next.add(row.id)
downloadingIds.value = next
try {
const blob = await downloadPlatformPriceBookSource(row.id)
if (!blob.size) throw new Error('原始价格表内容为空')
saveBlob(blob, row.originalFileName)
} catch (error) {
ElMessage.error(apiErrorMessage(error, '原始价格表下载失败,请稍后重试'))
} finally {
const current = new Set(downloadingIds.value)
current.delete(row.id)
downloadingIds.value = current
}
}
async function voidPending(row: PlatformPriceBookVersion) {
if (row.status !== 'PENDING_CONFIRMATION' || !canEdit.value) return
let reason = ''
try {
const result = await ElMessageBox.prompt(
`作废后,版本 V${row.versionNo} 将不能再确认生效;版本记录和原始文件会继续保留。`,
`作废待确认价格表 · V${row.versionNo} · ${priceBookScopeLabel(row.customerId)}`,
{
customClass: 'admin-density-confirm',
type: 'warning',
confirmButtonText: '确认作废',
cancelButtonText: '取消',
inputPlaceholder: '请输入作废原因',
inputValidator: (value) => value.trim() ? true : '请输入作废原因',
inputPattern: /^.{1,500}$/s,
inputErrorMessage: '作废原因不能超过 500 个字符',
},
)
reason = result.value.trim()
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error('作废操作未完成,请重试。')
return
}
const next = new Set(voidingIds.value)
next.add(row.id)
voidingIds.value = next
try {
await voidPlatformPriceBook(row.id, reason)
ElMessage.success(`价格表 V${row.versionNo} 已作废`)
await loadPriceBooks()
} catch (error) {
ElMessage.error(apiErrorMessage(error, '价格表作废失败,请稍后重试'))
} finally {
const current = new Set(voidingIds.value)
current.delete(row.id)
voidingIds.value = current
}
}
function detailActionLabel(row: PlatformPriceBookVersion) {
return row.status === 'PENDING_CONFIRMATION' && canEdit.value ? '查看并确认' : '查看详情'
}
watch(uploadOpen, (open) => {
if (!open && !uploading.value) resetUpload()
})
onMounted(() => {
void loadCustomerOptions()
void loadPriceBooks()
})
</script>
<template>
<AdminPage as="div" class="price-book-page admin-density" spacing="normal">
<ListPageLayout aria-label="承运商报价版本管理">
<section aria-live="polite"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError"
v-model:page="page"
v-model:page-size="pageSize"
:data="pagedRows"
:columns="columns"
preference-key="price-book-versions-table"
:loading="loading"
:total="rows.length"
:page-sizes="[10, 20, 50]"
row-key="id"
border
aria-label="船期价格表版本列表"
empty-text="暂无船期价格表版本"
>
<template #toolbar-start><h1>船期价格表</h1></template>
<template #filters>
<QueryToolbar
v-model="filters"
ariaLabel="船期价格表筛选"
:fields="queryFields"
preference-key="price-book-query"
@search="applyQuery"
/>
</template>
<template #notices><template v-if="loadError">
<LoadErrorState
title="船期价格表版本加载失败"
:message="loadErrorMessage"
:loading="loading"
@retry="loadPriceBooks()"
>
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
</template></template>
<template #toolbar-actions><el-button v-if="canEdit" type="primary" @click="openUpload"><FileUp :size="16" />上传价格表</el-button></template>
<template #actions="{ row }"><div class="table-actions density-actions row-actions admin-text-actions">
<el-button data-tone="neutral" class="density-action" size="small" @click="openDetail(row as PlatformPriceBookVersion)">{{ detailActionLabel(row as PlatformPriceBookVersion) }}</el-button>
<el-button data-tone="neutral" class="density-action"
size="small"
:loading="downloadingIds.has((row as PlatformPriceBookVersion).id)"
@click="downloadSource(row as PlatformPriceBookVersion)"
>原文件</el-button>
<el-button data-tone="danger" class="density-action"
v-if="canEdit && (row as PlatformPriceBookVersion).status === 'PENDING_CONFIRMATION'"
size="small"
:loading="voidingIds.has((row as PlatformPriceBookVersion).id)"
@click="voidPending(row as PlatformPriceBookVersion)"
>作废</el-button>
</div></template>
<template #version="{ row }"><div class="version-cell"><strong>V{{ (row as PlatformPriceBookVersion).versionNo }}</strong></div></template>
<template #scope="{ row }"><el-tag :type="(row as PlatformPriceBookVersion).customerId === '0' ? 'info' : 'warning'" effect="light">{{ priceBookScopeLabel((row as PlatformPriceBookVersion).customerId) }}</el-tag></template>
<template #status="{ row }"><el-tag :type="priceBookStatusMeta((row as PlatformPriceBookVersion).status).type" effect="light">{{ priceBookStatusMeta((row as PlatformPriceBookVersion).status).label }}</el-tag></template>
<template #contents="{ row }"><div class="metric-cell"><strong>{{ (row as PlatformPriceBookVersion).channelCount }}</strong><span>渠道</span><strong>{{ (row as PlatformPriceBookVersion).rateCount }}</strong><span>价格行</span></div></template>
<template #issues="{ row }"><div class="issue-counts"><span :class="{ 'is-blocking': (row as PlatformPriceBookVersion).blockingErrorCount }">阻断 {{ (row as PlatformPriceBookVersion).blockingErrorCount }}</span><span>警告 {{ (row as PlatformPriceBookVersion).warningCount }}</span></div></template>
<template #empty><el-empty description="暂无船期价格表版本" /></template>
<template #pagination-left>共 {{ rows.length }} 个版本 · {{ activeCount }} 个生效中 · {{ pendingCount }} 个待确认</template>
</DataTable></section>
</ListPageLayout>
<AppDialog class="admin-density-dialog"
v-model="uploadOpen"
title="上传船期价格表"
width="min(560px, calc(100vw - 28px))"
:confirm-loading="uploading"
:confirm-disabled="!uploadFile || Boolean(uploadFileError)"
confirm-text="上传并解析"
@confirm="submitUpload"
>
<div class="density-dialog-sections">
<el-form class="density-form" label-position="left" label-width="90px" :disabled="uploading">
<table class="density-table density-table--editable" aria-label="上传价格表资料"><tbody>
<tr><td><el-form-item label="报价范围"><template #label><span>报价范围<InlineHelp title="报价上传规则"><p>上传后先展示解析结果和仓库映射;确认后只替换同一报价范围的当前生效版本。客户专属报价生效后,计价时优先于平台公共报价。</p></InlineHelp></span></template><el-select v-model="uploadCustomerId" filterable :disabled="uploading" aria-label="上传报价范围"><el-option label="平台公共报价" value="0" /><el-option v-for="customer in customerOptions" :key="String(customer.id)" :label="`客户专属 · ${customer.tenantName || '未命名客户'}`" :value="String(customer.id)" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="价格表文件">
<input ref="uploadInput" class="visually-hidden-input" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" aria-label="选择 XLSX 船期价格表" :disabled="uploading" @change="selectUploadFile" />
<div class="upload-file-control"><el-button :disabled="uploading" @click="uploadInput?.click()"><FileSpreadsheet :size="16" />选择 XLSX 文件</el-button><span class="upload-file-name">{{ uploadFile?.name || '尚未选择文件' }}</span></div>
</el-form-item></td></tr>
</tbody></table>
</el-form>
<p class="density-dialog-note">{{ uploadFile ? (uploadFile.size / 1024 / 1024).toFixed(2) + ' MB' : '最大 10MB' }}</p>
<el-alert v-if="uploadFileError" :title="uploadFileError" type="error" :closable="false" show-icon />
</div>
</AppDialog>
</AdminPage>
</template>
<style scoped>
.price-book-page { min-width: 0; }
.version-cell { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.version-cell strong { color: var(--cc-text); }
.version-cell span, .metric-cell span { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.metric-cell, .issue-counts { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--admin-space-1) var(--admin-space-3); }
.metric-cell strong { color: var(--cc-text); font-size: var(--admin-font-body); }
.issue-counts { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.issue-counts .is-blocking, .is-danger { color: var(--el-color-danger); font-weight: 700; }
.visually-hidden-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; clip-path: inset(50%); }
.upload-file-control { display: flex; align-items: center; gap: var(--admin-space-5); }
.upload-file-name { min-width: 0; overflow-wrap: anywhere; }
@media (max-width: 720px) {
.table-actions :deep(.el-button) { min-height: 40px; }
.upload-file-control { align-items: stretch; flex-direction: column; }
}
</style>