93 lines
9.7 KiB
Vue
93 lines
9.7 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
|
||
import { Pencil, Plus, RefreshCw, Save, Trash2 } from '@lucide/vue'
|
||
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 { 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:view'))
|
||
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 closeEditor() {
|
||
if (saving.value) return
|
||
if (dirty.value) { try { await ElMessageBox.confirm('海外仓费用尚未保存,确定放弃修改吗?', '放弃编辑', { confirmButtonText: '放弃', cancelButtonText: '继续编辑', type: 'warning' }) } catch { return } }
|
||
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">
|
||
<header class="panel-heading"><div><h2>海外仓费用</h2><p>客户专属仓租和退货仓储费阶梯</p></div><el-button v-if="canEdit && config" type="primary" :loading="loading" @click="openEditor"><Pencil :size="16" />编辑费用</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"><RefreshCw :size="16" />重新加载</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><h3>仓租({{ config.currency }}/CBM/天)</h3><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><h3>退货仓储费({{ config.currency }}/托/天)</h3><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 v-model="visible" title="编辑海外仓费用" width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" :show-cancel="false" @confirm="save">
|
||
<el-form ref="formRef" :model="form" label-position="top"><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><div class="edit-groups"><div v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex"><div class="edit-group-heading"><h3>{{ groupIndex === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }} · {{ group.length }}档</h3><el-button type="primary" plain size="small" :disabled="form.tiers.length >= 100" @click="addTier(groupIndex === 0 ? 'STORAGE' : 'RETURN_STORAGE')"><Plus :size="15" />新增阶梯</el-button></div><table><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 link type="danger" :disabled="group.length <= 1" @click="removeTier(tier)"><Trash2 :size="15" />删除</el-button></td><td><el-input-number v-model="tier.startDay" :min="1" :max="36500" :controls="false" /></td><td><el-input-number v-if="tier.endDay !== null" v-model="tier.endDay" :min="1" :max="36500" :controls="false" /><span v-else>以上</span></td><td><el-input-number v-model="tier.unitPrice" :min="0" :precision="6" :controls="false" /></td></tr></tbody></table></div></div></el-form>
|
||
<template #footer><div class="dialog-actions"><el-button :disabled="saving" @click="closeEditor">取消</el-button><el-button type="primary" :loading="saving" @click="save"><Save :size="16" />保存费用</el-button></div></template>
|
||
</AppDialog>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.overseas-fee-panel { margin-top: 18px; border: 1px solid var(--cc-border); background: #fff; }.panel-heading { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:18px; border-bottom:1px solid var(--cc-border); }.panel-heading h2,.fee-groups h3,.edit-groups h3 { margin:0; color:var(--cc-text); font-size:16px; }.panel-heading p { margin:5px 0 0; color:var(--cc-muted); font-size:12px; }.fee-meta { display:flex; flex-wrap:wrap; gap:24px; padding:12px 18px; color:var(--cc-muted); font-size:12px; background:#f8fafc; }.fee-meta strong { color:var(--cc-text); }.fee-groups,.edit-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:18px; padding:18px; }.fee-groups table,.edit-groups table { width:100%; border-collapse:collapse; font-size:12px; }.fee-groups th,.fee-groups td,.edit-groups th,.edit-groups td { padding:9px 10px; border-bottom:1px solid var(--cc-border); text-align:left; }.fee-groups th,.edit-groups th { color:var(--cc-muted); font-weight:650; }.edit-groups :deep(.el-input-number) { width:130px; }.fee-error { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:20px; background:#fff4f3; }.fee-error p { margin:5px 0; color:#b42318; font-size:12px; }.fee-loading { min-height:220px; }.fee-empty { min-height:160px; display:grid; place-items:center; color:var(--cc-muted); }.dialog-actions { display:flex; justify-content:flex-end; gap:8px; }.dialog-actions :deep(.el-button + .el-button) { margin-left:0; }
|
||
.edit-group-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:10px; }
|
||
@media (max-width:760px) { .panel-heading,.fee-error { align-items:stretch; flex-direction:column; }.fee-groups,.edit-groups { grid-template-columns:1fr; padding:12px; }.edit-groups { overflow:auto; }.edit-groups > div { min-width:620px; }.panel-heading :deep(.el-button) { min-height:44px; } }
|
||
</style>
|