Files
admin-web/src/modules/customers/CustomerOverseasStorageFeePanel.vue
T

98 lines
11 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/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 { 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 closeEditor() {
if (saving.value) return
if (dirty.value) { try { await ElMessageBox.confirm('海外仓费用尚未保存,确定放弃修改吗?', '放弃编辑', { customClass: 'admin-density-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 admin-density customer-density">
<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><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 class="admin-density-dialog customer-density-dialog" 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 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><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 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></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">保存费用</el-button></div></template>
</AppDialog>
</section>
</template>
<style scoped>
.overseas-fee-panel { border: 1px solid var(--cc-border); background: var(--admin-surface, #fff); }.panel-heading { display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--cc-border); }.panel-heading h2 { margin:0; color:var(--cc-text); }.fee-groups h3 { margin:0; color:var(--cc-text); }.edit-groups h3 { margin:0; color:var(--cc-text); }.fee-meta { display:flex; flex-wrap:wrap; gap:var(--admin-space-7, 24px); padding:var(--admin-space-5, 12px) 18px; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); background:var(--admin-label-bg, #f8fafc); }.fee-meta strong { color:var(--cc-text); }.fee-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }.edit-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }.fee-groups > div { min-width:0; }.edit-groups > div { min-width:0; }.fee-groups table { width:100%; border-collapse:collapse; font-size:var(--admin-font-caption, 12px); }.edit-groups table { width:100%; border-collapse:collapse; font-size:var(--admin-font-caption, 12px); }.edit-groups table { table-layout:fixed; }.fee-groups th { border-bottom:1px solid var(--cc-border); text-align:left; }.fee-groups td { border-bottom:1px solid var(--cc-border); text-align:left; }.edit-groups th { border-bottom:1px solid var(--cc-border); text-align:left; }.edit-groups td { border-bottom:1px solid var(--cc-border); text-align:left; }.fee-groups th { color:var(--cc-muted); font-weight:650; }.edit-groups th { color:var(--cc-muted); font-weight:650; }.edit-groups th:first-child { width:76px; }.edit-groups td:first-child { width:76px; }.edit-groups :deep(.el-input-number) { width:100%; 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(--cc-muted); }.dialog-actions { display:flex; gap:var(--admin-space-3, 8px); }.dialog-actions :deep(.el-button + .el-button) { margin-left:0; }
.edit-group-heading { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-5, 12px); margin-bottom:var(--admin-space-4, 10px); }
.edit-group-heading h3 { min-width:0; }
.edit-group-heading :deep(.el-button) { flex:0 0 auto; }
@media (max-width:900px) { .edit-groups { grid-template-columns:1fr; } }
@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 > div { overflow-x:auto; }.edit-groups table { min-width:560px; }.panel-heading :deep(.el-button) { min-height:44px; } }
</style>