2026-08-08 13:32:27 +08:00
< script setup lang = "ts" >
2026-10-20 23:34:11 +08:00
import '@/styles/adminWorkbench.css'
2026-09-14 17:06:55 +08:00
import '@/styles/adminDensity.css'
import '@/modules/customers/customerDensity.css'
2026-08-08 13:32:27 +08:00
import { computed , onMounted , reactive , ref } from 'vue'
import { ElMessage } from 'element-plus'
2026-09-14 17:06:55 +08:00
2026-08-10 16:24:54 +08:00
import AdminPage from '@/shared/components/AdminPage.vue'
2026-08-08 13:32:27 +08:00
import AppDialog from '@/shared/components/AppDialog.vue'
2026-10-20 10:11:31 +08:00
import { customerDialogTitle } from '../customerDialogTitle'
2026-08-08 13:46:21 +08:00
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import DataTable from '@/shared/components/DataTable.vue'
2026-08-10 16:24:54 +08:00
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
2026-08-08 13:32:27 +08:00
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'
2026-08-11 18:43:56 +08:00
import { addFeeTier , removeFeeTier , validateFeeTiers } from '../overseasStorageFeeTiers'
2026-08-08 13:32:27 +08:00
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' ))
2026-08-11 19:59:15 +08:00
const filters = ref ({ keyword : '' })
2026-08-08 13:46:21 +08:00
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 },
2026-08-08 14:41:17 +08:00
{ key : 'storage' , slotName : 'storage' , label : '仓租(CBM/天)' , minWidth : 300 },
{ key : 'returnStorage' , slotName : 'returnStorage' , label : '退货仓储费(托/天)' , minWidth : 300 },
2026-08-08 13:46:21 +08:00
]
2026-08-08 13:32:27 +08:00
function clone ( tiers : OverseasStorageFeeTier []) { return tiers . map (( tier ) => ({ ... tier })) }
function label ( tier : OverseasStorageFeeTier ) { return tier . endDay === null ? ` ${ tier . startDay } 天以上` : ` ${ tier . startDay } - ${ tier . endDay } 天` }
2026-08-08 14:41:17 +08:00
function tiersFor ( row : OverseasStorageFeeConfig , feeType : OverseasStorageFeeTier [ 'feeType' ]) { return row . tiers . filter (( tier ) => tier . feeType === feeType ). sort (( a , b ) => a . sortNo - b . sortNo ) }
2026-08-08 13:32:27 +08:00
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 }
}
2026-08-11 19:59:15 +08:00
function search () { keyword . value = filters . value . keyword ; page . value = 1 ; void load () }
2026-08-08 13:32:27 +08:00
function openEdit ( row : OverseasStorageFeeConfig ) { editing . value = row ; form . currency = row . currency || 'USD' ; form . version = row . version ; form . tiers = clone ( row . tiers ); dialogOpen . value = true }
2026-08-11 18:43:56 +08:00
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 ) }
2026-08-08 13:32:27 +08:00
async function save () {
2026-08-11 18:43:56 +08:00
if ( ! editing . value ) return
const validationMessage = validateFeeTiers ( form . tiers )
if ( validationMessage ) { ElMessage . error ( validationMessage ); return }
2026-08-08 13:32:27 +08:00
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 >
2026-10-20 23:34:11 +08:00
< AdminPage class = "admin-workbench" >
2026-09-14 17:06:55 +08:00
< div class = "business-page overseas-fees-page admin-density customer-density" >
2026-08-10 16:24:54 +08:00
< ListPageLayout ariaLabel = "海外仓阶梯费用列表" >
2026-09-30 14:01:58 +08:00
2026-10-21 13:22:42 +08:00
< 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" >
2026-09-30 14:01:58 +08:00
< 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 >
2026-09-14 17:06:55 +08:00
< 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 >
2026-08-10 16:24:54 +08:00
< template # pagination -left =" { total : tableTotal } " >< span > 共 {{ tableTotal }} 个客户 </ span ></ template >
2026-09-30 14:01:58 +08:00
</ DataTable ></ section >
2026-08-10 16:24:54 +08:00
</ ListPageLayout >
2026-10-20 23:34:11 +08:00
< 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" >
2026-10-20 10:11:31 +08:00
< el-form label -position =" left " label -width =" 80px " class = "density-form density-dialog-sections" >
2026-10-21 17:49:04 +08:00
< 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 >
2026-10-20 10:11:31 +08:00
< div class = "customer-fee-tier-groups" >
2026-10-21 17:49:04 +08:00
< section v-for = "(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex" class="density-dialog-section" >
2026-10-20 10:11:31 +08:00
< 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 >
2026-10-20 23:34:11 +08:00
< 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 >
2026-10-20 10:11:31 +08:00
< 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 >
2026-08-08 13:32:27 +08:00
</ AppDialog >
</ div >
2026-08-10 16:24:54 +08:00
</ AdminPage >
2026-08-08 13:32:27 +08:00
</ template >
< style scoped >
2026-08-08 14:41:17 +08:00
. error - state { display : flex ; justify - content : space - between ; padding : 20 px ; color : # b42318 ; }
2026-09-14 17:54:21 +08:00
. tier - list { display : flex ; flex - wrap : wrap ; gap : var ( -- admin - space - 1 , 4 px ) var ( -- admin - space - 6 , 16 px ); margin : 0 ; padding : 2 px 0 ; white - space : normal ; }
2026-10-20 23:34:11 +08:00
. tier - list : empty :: after { content : '—' ; color : var ( -- workbench - muted ); }
2026-09-14 17:06:55 +08:00
. tier - item { display : flex ; align - items : baseline ; min - width : 0 ; max - width : 100 % ; line - height : 24 px ; }
2026-10-20 23:34:11 +08:00
. tier - item dt { flex : 0 0 auto ; color : var ( -- workbench - muted ); font - size : var ( -- admin - font - caption , 12 px ); }
. tier - item dd { min - width : 0 ; margin : 0 ; color : var ( -- workbench - text ); font - size : var ( -- admin - font - body , 13 px ); font - weight : 600 ; font - variant - numeric : tabular - nums ; overflow - wrap : anywhere ; }
2026-10-20 10:11:31 +08:00
@ media ( max - width : 720 px ) { . error - state { align - items : stretch ; flex - direction : column ; gap : 12 px ; } . error - state : deep (. el - button ) { width : 100 % ; min - height : 44 px ; } . tier - list { grid - template - columns : 1 fr ; } }
2026-08-08 13:32:27 +08:00
</ style >