Files
admin-web/src/modules/customers/pages/OverseasStorageFeesView.vue
T
2026-10-21 17:49:04 +08:00

121 lines
10 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 '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import '@/modules/customers/customerDensity.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { customerDialogTitle } from '../customerDialogTitle'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import DataTable from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listOverseasStorageFees, updateCustomerOverseasStorageFees, type OverseasStorageFeeConfig, type OverseasStorageFeeTier } from '../overseasStorageFee'
import { addFeeTier, removeFeeTier, validateFeeTiers } from '../overseasStorageFeeTiers'
const access = useAccessPreviewStore()
const loading = ref(false)
const error = ref<ReturnType<typeof getApiErrorDetails> | null>(null)
const rows = ref<OverseasStorageFeeConfig[]>([])
const page = ref(1)
const size = ref(20)
const total = ref(0)
const keyword = ref('')
const dialogOpen = ref(false)
const saving = ref(false)
const editing = ref<OverseasStorageFeeConfig | null>(null)
const form = reactive({ currency: 'USD', version: 0, tiers: [] as OverseasStorageFeeTier[] })
const storageTiers = computed(() => form.tiers.filter((tier) => tier.feeType === 'STORAGE'))
const returnTiers = computed(() => form.tiers.filter((tier) => tier.feeType === 'RETURN_STORAGE'))
const filters = ref({ keyword: '' })
const queryFields = [{ key: 'keyword', label: '客户', type: 'input' as const, placeholder: '搜索客户名称' }]
const columns = [
{ key: 'actions', slotName: 'actions', label: '操作', width: 100, fixed: 'left' as const },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 180 },
{ key: 'currency', prop: 'currency', label: '币种', width: 90 },
{ key: 'storage', slotName: 'storage', label: '仓租(CBM/天)', minWidth: 300 },
{ key: 'returnStorage', slotName: 'returnStorage', label: '退货仓储费(托/天)', minWidth: 300 },
]
function clone(tiers: OverseasStorageFeeTier[]) { return tiers.map((tier) => ({ ...tier })) }
function label(tier: OverseasStorageFeeTier) { return tier.endDay === null ? `${tier.startDay}天以上` : `${tier.startDay}-${tier.endDay}天` }
function tiersFor(row: OverseasStorageFeeConfig, feeType: OverseasStorageFeeTier['feeType']) { return row.tiers.filter((tier) => tier.feeType === feeType).sort((a, b) => a.sortNo - b.sortNo) }
async function load() {
loading.value = true; error.value = null
try { const result = await listOverseasStorageFees(page.value, size.value, keyword.value.trim() || undefined); rows.value = result.records; total.value = Number(result.total) }
catch (cause) { error.value = getApiErrorDetails(cause, '海外仓阶梯费用加载失败,请稍后重试') }
finally { loading.value = false }
}
function search() { keyword.value = filters.value.keyword; page.value = 1; void load() }
function openEdit(row: OverseasStorageFeeConfig) { editing.value = row; form.currency = row.currency || 'USD'; form.version = row.version; form.tiers = clone(row.tiers); dialogOpen.value = true }
function addTier(feeType: OverseasStorageFeeTier['feeType']) { const message = addFeeTier(form.tiers, feeType); if (message) ElMessage.error(message) }
function removeTier(tier: OverseasStorageFeeTier) { const message = removeFeeTier(form.tiers, tier); if (message) ElMessage.error(message) }
async function save() {
if (!editing.value) return
const validationMessage = validateFeeTiers(form.tiers)
if (validationMessage) { ElMessage.error(validationMessage); return }
saving.value = true
try { await updateCustomerOverseasStorageFees(editing.value.customerId, { customerId: editing.value.customerId, customerName: editing.value.customerName, currency: form.currency, version: form.version, tiers: form.tiers.map(({ feeType, billingUnit, startDay, endDay, unitPrice }) => ({ feeType, billingUnit, startDay, endDay, unitPrice })) }); dialogOpen.value = false; ElMessage.success('海外仓费用已保存'); await load() }
catch (cause) { ElMessage.error(getApiErrorDetails(cause, '保存失败,请刷新后重试').message) }
finally { saving.value = false }
}
onMounted(() => { void load() })
</script>
<template>
<AdminPage class="admin-workbench">
<div class="business-page overseas-fees-page admin-density customer-density">
<ListPageLayout ariaLabel="海外仓阶梯费用列表">
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-storage-fees-table" row-key="id" border empty-text="暂无海外仓阶梯费用配置" @update:page="load" @update:page-size="load">
<template #toolbar-start><h1>海外仓阶梯费用</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="queryFields" ariaLabel="海外仓阶梯费用筛选" preference-key="overseas-storage-fees-query" @search="search" /></template>
<template #notices><template v-if="error"><section class="surface-panel error-state" role="alert"><div><strong>{{ error.message }}</strong><ApiErrorMeta :details="error" /></div><el-button type="primary" @click="load">重新加载</el-button></section></template></template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button v-if="access.can('platform:overseas-storage-fee:edit')" class="density-action" size="small" plain @click="openEdit(row as OverseasStorageFeeConfig)">编辑</el-button></div></template>
<template #storage="{ row }">
<dl class="tier-list" aria-label="仓租阶梯单价,币种见本行币种列">
<div v-for="tier in tiersFor(row as OverseasStorageFeeConfig, 'STORAGE')" :key="tier.id" class="tier-item"><dt>{{ label(tier) }}:</dt><dd>{{ tier.unitPrice }}</dd></div>
</dl>
</template>
<template #returnStorage="{ row }">
<dl class="tier-list" aria-label="退货仓储费阶梯单价,币种见本行币种列">
<div v-for="tier in tiersFor(row as OverseasStorageFeeConfig, 'RETURN_STORAGE')" :key="tier.id" class="tier-item"><dt>{{ label(tier) }}:</dt><dd>{{ tier.unitPrice }}</dd></div>
</dl>
</template>
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 个客户</span></template>
</DataTable></section>
</ListPageLayout>
<AppDialog class="admin-density-dialog customer-density-dialog admin-workbench is-dialog" v-model="dialogOpen" :title="customerDialogTitle('编辑海外仓阶梯费用', { tenantName: editing?.customerName })" fullscreenable width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save">
<el-form label-position="left" label-width="80px" class="density-form density-dialog-sections">
<section class="density-dialog-section workbench-evidence"><table class="density-table density-table--editable customer-fee-currency" aria-label="费用币种"><tbody><tr><td><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /><el-option label="EUR" value="EUR" /></el-select></el-form-item></td></tr></tbody></table></section>
<div class="customer-fee-tier-groups">
<section v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex" class="density-dialog-section">
<div class="customer-fee-tier-heading"><h3 class="density-dialog-section__title">{{ groupIndex === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }} · {{ group.length }}档</h3><el-button class="density-action" data-tone="primary" type="primary" plain size="small" :disabled="form.tiers.length >= 100" @click="addTier(groupIndex === 0 ? 'STORAGE' : 'RETURN_STORAGE')">新增阶梯</el-button></div>
<div class="customer-fee-tier-table-wrap workbench-evidence"><table class="density-table density-table--editable customer-fee-tier-table" :aria-label="groupIndex === 0 ? '仓租阶梯填写' : '退货仓储费阶梯填写'"><thead><tr><th>操作</th><th>开始天数</th><th>结束天数</th><th>价格</th></tr></thead><tbody>
<tr v-for="tier in group" :key="tier.id"><td><el-button class="density-action" data-tone="danger" size="small" plain type="danger" :disabled="group.length <= 1" @click="removeTier(tier)">删除</el-button></td><td><el-input-number v-model="tier.startDay" :min="1" :max="36500" :controls="false" placeholder="请输入开始天数" /></td><td><el-input-number v-if="tier.endDay !== null" v-model="tier.endDay" :min="1" :max="36500" :controls="false" placeholder="请输入结束天数" /><span v-else>以上</span></td><td><el-input-number v-model="tier.unitPrice" :min="0" :precision="6" :controls="false" placeholder="请输入单价" /></td></tr>
</tbody></table></div>
</section>
</div>
</el-form>
</AppDialog>
</div>
</AdminPage>
</template>
<style scoped>
.error-state { display:flex; justify-content:space-between; padding:20px; color:#b42318; }
.tier-list { display:flex; flex-wrap:wrap; gap:var(--admin-space-1, 4px) var(--admin-space-6, 16px); margin:0; padding:2px 0; white-space:normal; }
.tier-list:empty::after { content:'—'; color:var(--workbench-muted); }
.tier-item { display:flex; align-items:baseline; min-width:0; max-width:100%; line-height:24px; }
.tier-item dt { flex:0 0 auto; color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); }
.tier-item dd { min-width:0; margin:0; color:var(--workbench-text); font-size:var(--admin-font-body, 13px); font-weight:600; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; }
@media (max-width:720px) { .error-state { align-items:stretch; flex-direction:column; gap:12px; } .error-state :deep(.el-button) { width:100%; min-height:44px; } .tier-list { grid-template-columns:1fr; } }
</style>