439 lines
19 KiB
Vue
439 lines
19 KiB
Vue
<script setup lang="ts">
|
||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||
import '@/styles/adminDensity.css'
|
||
import '@/styles/adminWorkbench.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: 210, fixed: 'left', slotName: 'actions' },
|
||
{ key: 'scope', label: '报价范围', minWidth: 160, slotName: 'scope' },
|
||
{ key: 'version', label: '价格表版本', width: 110, slotName: 'version' },
|
||
{ key: 'status', label: '状态', width: 100, slotName: 'status', afterColumn: 'version' },
|
||
{ key: 'originalFileName', prop: 'originalFileName', label: '原始文件', minWidth: 250, showOverflowTooltip: true, defaultVisible: false },
|
||
{ key: 'contents', label: '解析结果', minWidth: 170, slotName: 'contents' },
|
||
{ key: 'issues', label: '校验结果', width: 140, slotName: 'issues' },
|
||
{ key: 'createdAt', label: '上传时间', minWidth: 165, formatter: (row: PlatformPriceBookVersion) => formatDateTime(row.createdAt) },
|
||
{ key: 'effectivePeriod', label: '实际生效区间', minWidth: 260, 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 admin-workbench" spacing="normal">
|
||
<ListPageLayout aria-label="承运商报价版本管理">
|
||
|
||
|
||
|
||
|
||
<section aria-live="polite"><div class="workbench-table is-page-surface"><DataTable appearance="integrated" 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"><span><strong>{{ (row as PlatformPriceBookVersion).channelCount }}</strong> 渠道</span><span><strong>{{ (row as PlatformPriceBookVersion).rateCount }}</strong> 价格行</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></div></section>
|
||
</ListPageLayout>
|
||
|
||
<AppDialog class="admin-density-dialog admin-workbench is-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">
|
||
<div class="workbench-evidence"><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></div>
|
||
</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(--workbench-text); }
|
||
.version-cell span, .metric-cell span { color: var(--workbench-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 > span, .issue-counts > span { white-space: nowrap; }
|
||
.metric-cell strong { color: var(--workbench-text); font-size: var(--admin-font-body); }
|
||
.issue-counts { color: var(--workbench-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; width: 100%; min-width: 0; align-items: center; flex-wrap: wrap; gap: var(--admin-space-5); }
|
||
.upload-file-control > :deep(.el-button) { flex: 0 0 auto; }
|
||
.upload-file-name { min-width: 0; flex: 1 1 180px; 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>
|