Files
admin-web/src/modules/customers/CustomerOverseasStorageFeePanel.vue
T
2026-10-20 23:34:11 +08:00

110 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, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { customerDialogTitle } from './customerDialogTitle'
import { getCustomerOverseasStorageFees, updateCustomerOverseasStorageFees, type OverseasStorageFeeConfig, type OverseasStorageFeeTier } from './overseasStorageFee'
import { addFeeTier, removeFeeTier, validateFeeTiers } from './overseasStorageFeeTiers'
const props = defineProps<{ customerId: string; customerName: string }>()
const access = useAccessPreviewStore()
const canView = computed(() => access.can('platform:customer:list'))
const canEdit = computed(() => access.can('platform:customer:edit'))
const config = ref<OverseasStorageFeeConfig | null>(null)
const loading = ref(false)
const saving = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const visible = ref(false)
const dirty = ref(false)
const formRef = ref<FormInstance>()
const form = reactive<{ currency: string; tiers: OverseasStorageFeeTier[]; version: number }>({ currency: 'USD', tiers: [], version: 0 })
const currencies = ['USD', 'CNY', 'HKD', 'EUR', 'GBP']
const storageTiers = computed(() => form.tiers.filter((tier) => tier.feeType === 'STORAGE'))
const returnTiers = computed(() => form.tiers.filter((tier) => tier.feeType === 'RETURN_STORAGE'))
const { captureBaseline } = useUnsavedWorkRegistration({ key: `customer-overseas-storage-fee-${props.customerId}`, label: computed(() => `${props.customerName}的海外仓费用`), isOpen: visible, model: form, requiredPermission: computed(() => 'platform:customer:edit') })
watch(form, () => { if (visible.value) dirty.value = true }, { deep: true })
watch(() => props.customerId, () => { visible.value = false; void load() })
async function load() {
if (!canView.value) return
loading.value = true; error.value = null
try { config.value = await getCustomerOverseasStorageFees(props.customerId) }
catch (cause) { config.value = null; error.value = getApiErrorDetails(cause, '海外仓费用加载失败,请稍后重试') }
finally { loading.value = false }
}
function cloneTiers(tiers: OverseasStorageFeeTier[]) { return tiers.map((tier) => ({ ...tier })) }
async function openEditor() {
if (!config.value) return
form.currency = config.value.currency || 'USD'; form.tiers = cloneTiers(config.value.tiers); form.version = config.value.version
await nextTick(); dirty.value = false; visible.value = true; await nextTick(); captureBaseline(); formRef.value?.clearValidate()
}
async function beforeEditorClose(done: () => void) {
if (saving.value) return
if (dirty.value) { try { await ElMessageBox.confirm('海外仓费用尚未保存,确定放弃修改吗?', customerDialogTitle('放弃编辑', { tenantName: props.customerName }), { customClass: 'admin-density-confirm admin-workbench', confirmButtonText: '放弃', cancelButtonText: '继续编辑', type: 'warning' }) } catch { return } }
done()
}
async function closeEditor() {
await beforeEditorClose(() => { visible.value = false })
}
function validate() {
const message = validateFeeTiers(form.tiers)
if (message) { ElMessage.error(message); return false }
return 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 (!config.value || !validate()) return
saving.value = true
try {
await updateCustomerOverseasStorageFees(props.customerId, { customerId: props.customerId, customerName: props.customerName, currency: form.currency, version: form.version, tiers: form.tiers.map(({ feeType, billingUnit, startDay, endDay, unitPrice }) => ({ feeType, billingUnit, startDay, endDay, unitPrice })) })
await load(); dirty.value = false; visible.value = false; ElMessage.success('海外仓费用已保存')
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '海外仓费用保存失败,请刷新后重试').message) }
finally { saving.value = false }
}
function dayLabel(tier: OverseasStorageFeeTier) { return tier.endDay === null ? `第${tier.startDay}天以上` : `第${tier.startDay}-${tier.endDay}天` }
onMounted(load)
</script>
<template>
<section class="overseas-fee-panel admin-density customer-density admin-workbench">
<header class="panel-heading"><div><h2>海外仓费用</h2></div><el-button v-if="canEdit && config" type="primary" :loading="loading" @click="openEditor">编辑费用</el-button></header>
<div v-if="error" class="fee-error" role="alert"><div><strong>海外仓费用加载失败</strong><p>{{ error.message }}</p><ApiErrorMeta :details="error" /></div><el-button plain type="primary" :loading="loading" @click="load">重新加载</el-button></div>
<div v-else-if="loading && !config" v-loading="true" class="fee-loading" />
<template v-else-if="config">
<div class="fee-meta"><span>币种:<strong>{{ config.currency }}</strong></span><span>按整组保存,版本 {{ config.version }}</span></div>
<div class="fee-groups"><div class="workbench-evidence"><h3>仓租({{ config.currency }}/CBM/天)</h3><table class="density-table"><thead><tr><th>天数</th><th>价格</th></tr></thead><tbody><tr v-for="tier in config.tiers.filter((item) => item.feeType === 'STORAGE')" :key="tier.id"><td>{{ dayLabel(tier) }}</td><td>{{ tier.unitPrice }}</td></tr></tbody></table></div><div class="workbench-evidence"><h3>退货仓储费({{ config.currency }}/托/天)</h3><table class="density-table"><thead><tr><th>天数</th><th>价格</th></tr></thead><tbody><tr v-for="tier in config.tiers.filter((item) => item.feeType === 'RETURN_STORAGE')" :key="tier.id"><td>{{ dayLabel(tier) }}</td><td>{{ tier.unitPrice }}</td></tr></tbody></table></div></div>
</template>
<div v-else class="fee-empty">当前客户暂无海外仓费用配置</div>
<AppDialog class="admin-density-dialog customer-density-dialog admin-workbench is-dialog" v-model="visible" :title="customerDialogTitle('编辑海外仓费用', { tenantName: customerName })" fullscreenable width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" :show-cancel="false" :before-close="beforeEditorClose" @confirm="save">
<el-form ref="formRef" :model="form" label-position="left" label-width="80px" class="density-form density-dialog-sections">
<section class="density-dialog-section workbench-evidence" data-tone="work"><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 v-for="currency in currencies" :key="currency" :label="currency" :value="currency" /></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" data-tone="work">
<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>
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="saving" @click="closeEditor">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存费用</el-button></div></template>
</AppDialog>
</section>
</template>
<style scoped>
.overseas-fee-panel { border: 1px solid var(--workbench-border); background: var(--workbench-surface); }.panel-heading { display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--workbench-border); }.panel-heading h2 { margin:0; color:var(--workbench-text); }.fee-groups h3 { margin:0; color:var(--workbench-text); }.fee-meta { display:flex; flex-wrap:wrap; gap:var(--admin-space-7, 24px); padding:var(--admin-space-5, 12px) 18px; color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); background:var(--workbench-surface-soft); }.fee-meta strong { color:var(--workbench-text); }.fee-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }.fee-groups > div { min-width:0; }.fee-error { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-6, 16px); padding:20px; background:#fff4f3; }.fee-error p { margin:5px 0; color:#b42318; font-size:var(--admin-font-caption, 12px); }.fee-empty { display:grid; place-items:center; color:var(--workbench-muted); }
@media (max-width:760px) { .panel-heading,.fee-error { align-items:stretch; flex-direction:column; }.fee-groups { grid-template-columns:1fr; padding:12px; }.panel-heading :deep(.el-button) { min-height:44px; } }
</style>