119 lines
10 KiB
Vue
119 lines
10 KiB
Vue
<script setup lang="ts">
|
||
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 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>
|
||
<div class="business-page overseas-fees-page admin-density customer-density">
|
||
<ListPageLayout ariaLabel="海外仓阶梯费用列表">
|
||
|
||
|
||
<section><DataTable appearance="integrated" :stripe="false" 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" v-model="dialogOpen" title="编辑海外仓阶梯费用" width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save">
|
||
<el-form label-position="top"><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><div class="groups"><div v-for="(group, index) in [storageTiers, returnTiers]" :key="index"><div class="group-heading"><h3>{{ index === 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(index === 0 ? 'STORAGE' : 'RETURN_STORAGE')">新增阶梯</el-button></div><el-table :data="group" size="small" border stripe><el-table-column label="操作" width="88" fixed="left"><template #default="{ row }"><el-button class="density-action" data-tone="danger" size="small" plain type="danger" :disabled="group.length <= 1" @click="removeTier(row as OverseasStorageFeeTier)">删除</el-button></template></el-table-column><el-table-column label="开始天数"><template #default="{ row }"><el-input-number v-model="row.startDay" :min="1" :max="36500" :controls="false" placeholder="请输入开始天数" /></template></el-table-column><el-table-column label="结束天数"><template #default="{ row }"><el-input-number v-if="row.endDay !== null" v-model="row.endDay" :min="1" :max="36500" :controls="false" placeholder="请输入结束天数" /><span v-else>以上</span></template></el-table-column><el-table-column label="价格"><template #default="{ row }"><el-input-number v-model="row.unitPrice" :min="0" :precision="6" :controls="false" placeholder="请输入单价" /></template></el-table-column></el-table></div></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(--cc-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(--cc-muted); font-size:var(--admin-font-caption, 12px); }
|
||
.tier-item dd { min-width:0; margin:0; color:var(--cc-text); font-size:var(--admin-font-body, 13px); font-weight:600; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; }
|
||
.groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||
.groups > div { min-width:0; }
|
||
.groups :deep(.el-table) { width:100%; }
|
||
.groups :deep(.el-input-number) { width:100%; min-width:0; }
|
||
.groups h3 { margin:0 0 var(--admin-space-4, 10px); }
|
||
.group-heading { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-5, 12px); margin-bottom:var(--admin-space-4, 10px); }
|
||
.group-heading h3 { min-width:0; margin:0; }
|
||
.group-heading :deep(.el-button) { flex:0 0 auto; }
|
||
@media (max-width:900px) { .groups { grid-template-columns:1fr; } }
|
||
@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; } .groups > div { overflow-x:auto; } .groups :deep(.el-table) { min-width:560px; } }
|
||
</style>
|