This commit is contained in:
2026-08-11 18:43:56 +08:00
parent 6456278ec8
commit 6622815d6c
37 changed files with 739 additions and 205 deletions
@@ -1,13 +1,14 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
import { Pencil, RefreshCw, Save } from '@lucide/vue'
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()
@@ -48,19 +49,12 @@ async function closeEditor() {
visible.value = false
}
function validate() {
if (storageTiers.value.length !== 5 || returnTiers.value.length !== 4) { ElMessage.error('仓租必须为5档,退货仓储费必须为4档'); return false }
for (const [label, tiers] of [['仓租', storageTiers.value], ['退货仓储费', returnTiers.value] ] as const) {
let expected = 1
for (const [index, tier] of tiers.entries()) {
if (!Number.isInteger(tier.startDay) || tier.startDay !== expected || tier.startDay < 1) { ElMessage.error(`${label}第${index + 1}档天数必须连续且从第1天开始`); return false }
if (!Number.isFinite(tier.unitPrice) || tier.unitPrice < 0) { ElMessage.error(`${label}第${index + 1}档价格不能小于0`); return false }
if (index === tiers.length - 1) { if (tier.endDay !== null) { ElMessage.error(`${label}最后一档必须为“以上”`); return false } }
else if (!Number.isInteger(tier.endDay) || tier.endDay! < tier.startDay) { ElMessage.error(`${label}第${index + 1}档结束天数无效`); return false }
if (tier.endDay !== null) expected = tier.endDay + 1
}
}
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
@@ -85,7 +79,7 @@ onMounted(load)
</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"><h3>{{ groupIndex === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }}</h3><table><thead><tr><th>开始天数</th><th>结束天数</th><th>价格</th></tr></thead><tbody><tr v-for="tier in group" :key="tier.id"><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>
<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>
@@ -93,5 +87,6 @@ onMounted(load)
<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>
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { addFeeTier, removeFeeTier, validateFeeTiers } from './overseasStorageFeeTiers'
import type { OverseasFeeType, OverseasStorageFeeTier } from './overseasStorageFee'
function tier(id: string, feeType: OverseasFeeType, startDay: number, endDay: number | null): OverseasStorageFeeTier {
return { id, feeType, billingUnit: feeType === 'STORAGE' ? 'CBM' : 'PALLET', startDay, endDay, unitPrice: 1, sortNo: 1 }
}
describe('overseas storage fee tier editing', () => {
it('adds a new open-ended tier and closes the previous tail', () => {
const tiers = [tier('s1', 'STORAGE', 1, null), tier('r1', 'RETURN_STORAGE', 1, null)]
expect(addFeeTier(tiers, 'STORAGE')).toBeNull()
expect(tiers.filter((item) => item.feeType === 'STORAGE').map(({ startDay, endDay }) => ({ startDay, endDay })))
.toEqual([{ startDay: 1, endDay: 1 }, { startDay: 2, endDay: null }])
expect(validateFeeTiers(tiers)).toBeNull()
})
it('removes a middle tier and extends the previous range', () => {
const tiers = [
tier('s1', 'STORAGE', 1, 7), tier('s2', 'STORAGE', 8, 30), tier('s3', 'STORAGE', 31, null),
tier('r1', 'RETURN_STORAGE', 1, null),
]
expect(removeFeeTier(tiers, tiers[1]!)).toBeNull()
expect(tiers.slice(0, 2).map(({ startDay, endDay }) => ({ startDay, endDay })))
.toEqual([{ startDay: 1, endDay: 30 }, { startDay: 31, endDay: null }])
expect(validateFeeTiers(tiers)).toBeNull()
})
it('does not delete the only tier of a fee type', () => {
const tiers = [tier('s1', 'STORAGE', 1, null), tier('r1', 'RETURN_STORAGE', 1, null)]
expect(removeFeeTier(tiers, tiers[0]!)).toBe('每种费用至少需要保留1档')
})
})
@@ -0,0 +1,61 @@
import type { OverseasFeeType, OverseasStorageFeeTier } from './overseasStorageFee'
let temporaryTierId = 0
export function tiersByType(tiers: OverseasStorageFeeTier[], feeType: OverseasFeeType) {
return tiers.filter((tier) => tier.feeType === feeType)
}
export function addFeeTier(tiers: OverseasStorageFeeTier[], feeType: OverseasFeeType) {
if (tiers.length >= 100) return '阶梯费用明细最多为100组'
const group = tiersByType(tiers, feeType)
const last = group.at(-1)
if (!last) return '每种费用至少需要保留1档'
if (last.startDay >= 36500) return '当前尾档已从第36500天开始,无法继续新增'
const startDay = last.startDay + 1
last.endDay = startDay - 1
const newTier: OverseasStorageFeeTier = {
...last,
id: `new-${feeType}-${++temporaryTierId}`,
startDay,
endDay: null,
sortNo: last.sortNo + 1,
}
const lastIndex = tiers.indexOf(last)
tiers.splice(lastIndex + 1, 0, newTier)
return null
}
export function removeFeeTier(tiers: OverseasStorageFeeTier[], tier: OverseasStorageFeeTier) {
const group = tiersByType(tiers, tier.feeType)
if (group.length <= 1) return '每种费用至少需要保留1档'
const groupIndex = group.indexOf(tier)
const previous = group[groupIndex - 1]
const next = group[groupIndex + 1]
if (previous) previous.endDay = tier.endDay
else if (next) next.startDay = 1
tiers.splice(tiers.indexOf(tier), 1)
return null
}
export function validateFeeTiers(tiers: OverseasStorageFeeTier[]) {
if (tiers.length < 2 || tiers.length > 100) return '仓租和退货仓储费必须各保留至少1档,合计不超过100档'
for (const [feeType, label] of [['STORAGE', '仓租'], ['RETURN_STORAGE', '退货仓储费']] as const) {
const group = tiersByType(tiers, feeType)
if (group.length === 0) return `${label}至少需要保留1档`
let expected = 1
for (const [index, tier] of group.entries()) {
if (!Number.isInteger(tier.startDay) || tier.startDay !== expected || tier.startDay < 1) return `${label}第${index + 1}档天数必须连续且从第1天开始`
if (!Number.isFinite(tier.unitPrice) || tier.unitPrice < 0) return `${label}第${index + 1}档价格不能小于0`
if (index === group.length - 1) {
if (tier.endDay !== null) return `${label}最后一档必须为“以上”`
} else if (!Number.isInteger(tier.endDay) || tier.endDay! < tier.startDay) {
return `${label}第${index + 1}档结束天数无效`
}
if (tier.endDay !== null) expected = tier.endDay + 1
}
}
return null
}
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Pencil } from '@lucide/vue'
import { Pencil, Plus, Trash2 } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
@@ -11,6 +11,7 @@ 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)
@@ -49,8 +50,12 @@ async function load() {
}
function search() { keyword.value = filters.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 || storageTiers.value.length !== 5 || returnTiers.value.length !== 4) { ElMessage.error('费用配置必须包含仓租5档和退货仓储费4档'); return }
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) }
@@ -75,7 +80,7 @@ onMounted(() => { void load() })
</section>
</ListPageLayout>
<AppDialog 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"><h3>{{ index === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }}</h3><el-table :data="group" size="small"><el-table-column label="开始天数"><template #default="{ row }"><el-input-number v-model="row.startDay" :min="1" :controls="false" /></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" :controls="false" /><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" /></template></el-table-column></el-table></div></div></el-form>
<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 type="primary" plain size="small" :disabled="form.tiers.length >= 100" @click="addTier(index === 0 ? 'STORAGE' : 'RETURN_STORAGE')"><Plus :size="15" />新增阶梯</el-button></div><el-table :data="group" size="small"><el-table-column label="操作" width="88" fixed="left"><template #default="{ row }"><el-button link type="danger" :disabled="group.length <= 1" @click="removeTier(row as OverseasStorageFeeTier)"><Trash2 :size="15" />删除</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" /></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" /><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" /></template></el-table-column></el-table></div></div></el-form>
</AppDialog>
</div>
</AdminPage>
@@ -89,5 +94,7 @@ onMounted(() => { void load() })
.tier-item strong { color:var(--cc-text); font-size:12px; font-weight:650; white-space:nowrap; }
.groups { display:grid; grid-template-columns:1fr 1fr; gap:20px; }
.groups h3 { margin:0 0 10px; font-size:15px; }
.group-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:10px; }
.group-heading h3 { margin:0; }
@media (max-width:720px) { .error-state { align-items:stretch; flex-direction:column; gap:12px; } .error-state :deep(.el-button) { width:100%; min-height:44px; } .groups,.tier-list { grid-template-columns:1fr; } }
</style>