@@ -0,0 +1,311 @@
< script setup lang = "ts" >
import { computed , nextTick , onMounted , reactive , ref , watch } from 'vue'
import { CircleSlash2 , Eye , Plus , RefreshCw , Scale } from '@lucide/vue'
import { ElMessage , ElMessageBox , type FormInstance , type FormRules } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import {
createCustomerPricingRuleVersion ,
disableCustomerPricingRule ,
getCustomerPricingRule ,
listCustomerPricingRules ,
type CustomerPricingRule ,
type CustomerRuleFormModel ,
} from '@/modules/fba-pricing'
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.vue'
const props = defineProps < {
customerId : string
customerName : string
customerCode ? : string
} > ( )
const access = useAccessPreviewStore ( )
const canView = computed ( ( ) => access . can ( 'firstleg:fba-pricing:rule:view' ) )
const canEdit = computed ( ( ) => canView . value && access . can ( 'firstleg:fba-pricing:rule:edit' ) )
const rules = ref < CustomerPricingRule [ ] > ( [ ] )
const loading = ref ( false )
const loadError = ref < ApiErrorDetails | null > ( null )
const detailVisible = ref ( false )
const detailLoading = ref ( false )
const detailError = ref < ApiErrorDetails | null > ( null )
const selectedRule = ref < CustomerPricingRule | null > ( null )
const editorVisible = ref ( false )
const editorSaving = ref ( false )
const editorDirty = ref ( false )
const editorForm = ref < FormInstance > ( )
const page = ref ( 1 )
const pageSize = ref ( 20 )
const columns : DataTableColumn [ ] = [
{ key : 'actions' , label : '操作' , width : 210 , fixed : 'left' , slotName : 'actions' } ,
{ key : 'rule' , label : '规则版本' , minWidth : 160 , slotName : 'rule' } ,
{ key : 'businessType' , label : '业务类型' , minWidth : 120 , slotName : 'businessType' } ,
{ key : 'chargeMode' , label : '计费方式' , width : 105 , slotName : 'chargeMode' } ,
{ key : 'taxMode' , label : '税务模式' , width : 100 , slotName : 'taxMode' } ,
{ key : 'conversionFactor' , prop : 'conversionFactor' , label : '换算系数' , width : 105 } ,
{ key : 'rounding' , label : '舍入规则' , minWidth : 165 , slotName : 'rounding' } ,
{ key : 'currency' , prop : 'currency' , label : '币种' , width : 80 } ,
{ key : 'period' , label : '有效期' , minWidth : 225 , slotName : 'period' } ,
{ key : 'status' , label : '状态' , width : 90 , slotName : 'status' } ,
]
const pagedRules = computed ( ( ) => rules . value . slice ( ( page . value - 1 ) * pageSize . value , page . value * pageSize . value ) )
const emptyForm = ( ) : CustomerRuleFormModel => ( {
ruleCode : '' ,
customerId : props . customerId ,
customerName : props . customerName ,
businessType : 'FBA_SEA' ,
chargeMode : 'KG' ,
taxMode : 'TAX_INCLUDED' ,
conversionFactor : 167 ,
roundingScale : 3 ,
roundingMode : 'CEILING' ,
currency : 'CNY' ,
effectiveFrom : '' ,
effectiveTo : null ,
remark : null ,
} )
const form = reactive < CustomerRuleFormModel > ( emptyForm ( ) )
const formRules : FormRules < CustomerRuleFormModel > = {
ruleCode : [ { required : true , message : '请输入规则编码' , trigger : 'blur' } ] ,
businessType : [ { required : true , message : '请输入业务类型' , trigger : 'blur' } ] ,
chargeMode : [ { required : true , message : '请选择计费方式' , trigger : 'change' } ] ,
taxMode : [ { required : true , message : '请选择税务模式' , trigger : 'change' } ] ,
conversionFactor : [ { required : true , message : '请输入大于 0 的换算系数' , trigger : 'blur' , type : 'number' , min : 0.000001 } ] ,
roundingScale : [ { required : true , message : '请选择保留位数' , trigger : 'change' , type : 'number' , min : 0 , max : 4 } ] ,
roundingMode : [ { required : true , message : '请选择舍入方式' , trigger : 'change' } ] ,
currency : [ { required : true , message : '请选择币种' , trigger : 'change' } ] ,
effectiveFrom : [ { required : true , message : '请选择生效时间' , trigger : 'change' } ] ,
}
const { captureBaseline } = useUnsavedWorkRegistration ( {
key : ` customer-pricing-rule- ${ props . customerId } ` ,
label : computed ( ( ) => ` ${ props . customerName } 的计费规则版本 ` ) ,
isOpen : editorVisible ,
model : form ,
requiredPermission : computed ( ( ) => 'firstleg:fba-pricing:rule:edit' ) ,
} )
watch ( form , ( ) => {
if ( editorVisible . value ) editorDirty . value = true
} , { deep : true } )
async function loadRules ( ) {
if ( ! canView . value ) return
loading . value = true
loadError . value = null
try {
rules . value = await listCustomerPricingRules ( { tenantId : props . customerId } )
page . value = 1
} catch ( error ) {
rules . value = [ ]
loadError . value = getApiErrorDetails ( error , '客户计费规则加载失败,请稍后重试' )
} finally {
loading . value = false
}
}
function resetForm ( source ? : CustomerPricingRule ) {
Object . assign ( form , emptyForm ( ) , source ? {
ruleCode : source . ruleCode ,
businessType : source . businessType ,
chargeMode : source . chargeMode ,
taxMode : source . taxMode ,
conversionFactor : source . conversionFactor ,
roundingScale : source . roundingScale ,
roundingMode : source . roundingMode ,
currency : source . currency ,
remark : source . remark ,
} : { } )
form . customerId = props . customerId
form . customerName = props . customerName
}
async function openEditor ( source ? : CustomerPricingRule ) {
resetForm ( source )
editorDirty . value = false
editorVisible . value = true
await nextTick ( )
captureBaseline ( )
editorForm . value ? . clearValidate ( )
}
async function confirmAbandon ( ) : Promise < boolean > {
try {
await ElMessageBox . confirm ( '尚有未保存的规则版本,确定放弃吗?' , '放弃编辑' , {
confirmButtonText : '放弃' , cancelButtonText : '继续编辑' , type : 'warning' ,
} )
return true
} catch {
return false
}
}
async function beforeEditorClose ( done : ( ) => void ) {
if ( editorSaving . value ) return
if ( ! editorDirty . value || await confirmAbandon ( ) ) done ( )
}
async function closeEditor ( ) {
if ( editorDirty . value && ! await confirmAbandon ( ) ) {
editorVisible . value = true
return
}
editorVisible . value = false
}
async function saveVersion ( ) {
if ( ! await editorForm . value ? . validate ( ) ) return
if ( form . effectiveTo && form . effectiveTo <= form . effectiveFrom ) {
ElMessage . warning ( '失效时间必须晚于生效时间' )
return
}
editorSaving . value = true
try {
await createCustomerPricingRuleVersion ( props . customerId , {
... form ,
ruleCode : form . ruleCode . trim ( ) ,
customerId : props . customerId ,
customerName : props . customerName ,
businessType : form . businessType . trim ( ) ,
effectiveTo : form . effectiveTo || null ,
remark : form . remark ? . trim ( ) || null ,
} )
editorDirty . value = false
editorVisible . value = false
ElMessage . success ( '计费规则新版本已创建' )
await loadRules ( )
} catch ( error ) {
ElMessage . error ( getApiErrorDetails ( error , '计费规则版本创建失败' ) . message )
} finally {
editorSaving . value = false
}
}
async function openDetail ( row : CustomerPricingRule ) {
detailVisible . value = true
detailLoading . value = true
detailError . value = null
selectedRule . value = row
try {
selectedRule . value = await getCustomerPricingRule ( props . customerId , row . id )
} catch ( error ) {
detailError . value = getApiErrorDetails ( error , '计费规则详情加载失败' )
} finally {
detailLoading . value = false
}
}
async function disableRule ( row : CustomerPricingRule ) {
try {
await ElMessageBox . confirm ( ` 确认停用规则“ ${ row . ruleCode } ”V ${ row . ruleVersion ? ? '—' } 吗?历史费用不会改变。 ` , '停用计费规则' , {
confirmButtonText : '确认停用' , cancelButtonText : '取消' , type : 'warning' ,
} )
await disableCustomerPricingRule ( props . customerId , row . id )
ElMessage . success ( '计费规则已停用' )
await loadRules ( )
} catch ( error ) {
if ( error === 'cancel' || error === 'close' ) return
ElMessage . error ( getApiErrorDetails ( error , '计费规则停用失败' ) . message )
}
}
function asRule ( value : unknown ) { return value as CustomerPricingRule }
function formatDate ( value : string | null ) { return value ? value . replace ( 'T' , ' ' ) . slice ( 0 , 16 ) : '长期有效' }
function modeLabel ( value : string ) { return value === 'KG' ? '按 KG' : value === 'CBM' ? '按 CBM' : value }
function taxLabel ( value : string ) { return value === 'TAX_INCLUDED' ? '包税' : value === 'TAX_EXCLUDED' ? '不包税' : value }
function businessTypeLabel ( value : string ) { return value === 'FBA_SEA' ? 'FBA 海运' : value }
onMounted ( loadRules )
< / script >
< template >
< div class = "billing-rules-panel" >
< div class = "panel-title billing-heading" >
< div > < h2 > 计费规则 < / h2 > < p > { { customerName } } { { customerCode ? ` ( ${ customerCode } ) ` : '' } } 的专属 FBA 计费版本 < / p > < / div >
< div class = "heading-actions" >
< el-button v-if = "canView" :loading="loading" aria-label="刷新计费规则" @click="loadRules"><RefreshCw :size="16" / > 刷新 < / el-button >
< el-button v-if = "canEdit" type="primary" :disabled="loading" @click="openEditor()"><Plus :size="16" / > 新建规则 < / el-button >
< / div >
< / div >
< div v-if = "!canView" class="billing-state" >
< Scale :size = "30" / > < strong > 暂无计费规则查看权限 < / strong > < p > 需要权限 firstleg : fba - pricing : rule : view 。 < / p >
< / div >
< div v-else-if = "loadError" class="billing-error" role="alert" >
< div > < strong > 客户计费规则加载失败 < / strong > < p > { { loadError . message } } < / p > < ApiErrorMeta :details = "loadError" / > < / div >
< el-button type = "primary" plain :loading = "loading" @click ="loadRules" > 重新加载 < / el -button >
< / div >
< DataTable
v-else
v -model :page = "page"
v -model :page-size = "pageSize"
:data = "pagedRules"
:columns = "columns"
:loading = "loading"
:total = "rules.length"
: page -sizes = " [ 10 , 20 , 50 ] "
row -key = " id "
empty -text = " 当前客户暂无计费规则 "
>
< template # actions = "{ row }" >
< div class = "row-actions" >
< el-button link type = "primary" @click ="openDetail(asRule(row))" > < Eye :size = "15" / > 详情 < / el-button >
< el-button v-if = "canEdit" link type="primary" @click="openEditor(asRule(row))"><Plus :size="15" / > 新版本 < / el-button >
< el-button v-if = "canEdit && row.status === 'ACTIVE'" link type="danger" @click="disableRule(asRule(row))"><CircleSlash2 :size="15" / > 停用 < / el-button >
< / div >
< / template >
< template # rule = "{ row }" > < div class = "primary-cell" > < strong > { { row . ruleCode } } < / strong > < small > V { { row . ruleVersion ? ? '—' } } < / small > < / div > < / template >
< template # businessType = "{ row }" > { { businessTypeLabel ( row . businessType ) } } < / template >
< template # chargeMode = "{ row }" > < el-tag effect = "plain" > { { modeLabel ( row . chargeMode ) } } < / el-tag > < / template >
< template # taxMode = "{ row }" > { { taxLabel ( row . taxMode ) } } < / template >
< template # rounding = "{ row }" > 保留 { { row . roundingScale } } 位 · { { row . roundingMode } } < / template >
< template # period = "{ row }" > < span > { { formatDate ( row . effectiveFrom ) } } < / span > < small class = "subline" > 至 { { formatDate ( row . effectiveTo ) } } < / small > < / template >
< template # status = "{ row }" > < el-tag : type = "row.status === 'ACTIVE' ? 'success' : 'info'" effect = "light" > { { row . status === 'ACTIVE' ? '生效中' : '已停用' } } < / el-tag > < / template >
< template # empty >
< div class = "billing-state" > < Scale :size = "28" / > < strong > 当前客户暂无计费规则 < / strong > < p v-if = "canEdit" > 创建第一条规则后 , 入库计费才能匹配该客户的计费口径 。 < / p > < / div >
< / template >
< template # pagination -left > 共 { { rules . length } } 个规则版本 < / template >
< / DataTable >
< AppDrawer v-model = "detailVisible" title="计费规则详情" size="min(600px, 94vw)" :loading="detailLoading" :show-footer="false" >
< div v-if = "detailError" class="billing-error" role="alert"><div><strong>规则详情加载失败</strong><p>{{ detailError.message }}</p><ApiErrorMeta :details="detailError" / > < / div > < / div >
< div v-else-if = "selectedRule" class="rule-detail" >
< section > < h3 > 规则归属 < / h3 > < dl class = "detail-grid" > < div > < dt > 规则编码 < / dt > < dd > { { selectedRule . ruleCode } } < / dd > < / div > < div > < dt > 版本 < / dt > < dd > V { { selectedRule . ruleVersion ? ? '—' } } < / dd > < / div > < div > < dt > 适用客户 < / dt > < dd > { { selectedRule . customerName } } < / dd > < / div > < div > < dt > 业务类型 < / dt > < dd > { { businessTypeLabel ( selectedRule . businessType ) } } < / dd > < / div > < div > < dt > 状态 < / dt > < dd > < el-tag : type = "selectedRule.status === 'ACTIVE' ? 'success' : 'info'" > { { selectedRule . status === 'ACTIVE' ? '生效中' : '已停用' } } < / el-tag > < / dd > < / div > < / dl > < / section >
< section > < h3 > 计费口径 < / h3 > < dl class = "detail-grid" > < div > < dt > 计费方式 < / dt > < dd > { { modeLabel ( selectedRule . chargeMode ) } } < / dd > < / div > < div > < dt > 税务模式 < / dt > < dd > { { taxLabel ( selectedRule . taxMode ) } } < / dd > < / div > < div > < dt > 换算系数 < / dt > < dd > { { selectedRule . conversionFactor } } < / dd > < / div > < div > < dt > 舍入规则 < / dt > < dd > 保留 { { selectedRule . roundingScale } } 位 , { { selectedRule . roundingMode } } < / dd > < / div > < div > < dt > 币种 < / dt > < dd > { { selectedRule . currency } } < / dd > < / div > < / dl > < / section >
< section > < h3 > 版本有效期 < / h3 > < dl class = "detail-grid" > < div > < dt > 生效时间 < / dt > < dd > { { formatDate ( selectedRule . effectiveFrom ) } } < / dd > < / div > < div > < dt > 失效时间 < / dt > < dd > { { formatDate ( selectedRule . effectiveTo ) } } < / dd > < / div > < div class = "detail-wide" > < dt > 备注 < / dt > < dd > { { selectedRule . remark || '—' } } < / dd > < / div > < / dl > < / section >
< / div >
< / AppDrawer >
< AppDrawer v-model = "editorVisible" title="创建计费规则版本" size="min(760px, 94vw)" :confirm-loading="editorSaving" confirm-text="创建版本" :before-close="beforeEditorClose" @confirm="saveVersion" @cancel="closeEditor" >
< el -form ref = "editorForm" :model = "form" :rules = "formRules" label -position = " top " class = "rule-form" >
< div class = "customer-context" > < span > 适用客户 < / span > < strong > { { customerName } } < / strong > < small > { { customerCode || customerId } } < / small > < / div >
< section class = "form-section" > < h3 > 规则归属 < / h3 > < div class = "form-grid" > < el-form-item label = "规则编码" prop = "ruleCode" > < el-input v-model = "form.ruleCode" maxlength="50" placeholder="例如 FBA-SEA-TAX-KG" /></el-form-item><el-form-item label="业务类型" prop="businessType"><el-input v-model="form.businessType" maxlength="32" placeholder="例如 FBA_SEA" / > < / el-form-item > < / div > < / section >
< section class = "form-section" > < h3 > 计费口径 < / h3 > < div class = "form-grid" > < el-form-item label = "计费方式" prop = "chargeMode" > < el-segmented v-model = "form.chargeMode" :options="[{ label: '按 KG', value: 'KG' }, { label: '按 CBM', value: 'CBM' }]" /></el-form-item><el-form-item label="税务模式" prop="taxMode"><el-select v-model="form.taxMode"><el-option label="包税" value="TAX_INCLUDED" /><el-option label="不包税" value="TAX_EXCLUDED" /></el-select></el-form-item><el-form-item label="换算系数" prop="conversionFactor"><el-input-number v-model="form.conversionFactor" :min="0.000001" :precision="6" :controls="false" /></el-form-item><el-form-item label="保留小数位" prop="roundingScale"><el-select v-model="form.roundingScale"><el-option v-for="scale in [0, 1, 2, 3, 4]" :key="scale" :label="`${scale} 位`" :value="scale" /></el-select></el-form-item><el-form-item label="舍入方式" prop="roundingMode"><el-select v-model="form.roundingMode"><el-option label="向上取整 CEILING" value="CEILING" /><el-option label="向上舍入 UP" value="UP" /><el-option label="向下舍入 DOWN" value="DOWN" /><el-option label="向下取整 FLOOR" value="FLOOR" /><el-option label="四舍五入 HALF_UP" value="HALF_UP" /><el-option label="五舍六入 HALF_DOWN" value="HALF_DOWN" /><el-option label="银行家舍入 HALF_EVEN" value="HALF_EVEN" /></el-select></el-form-item><el-form-item label="币种" prop="currency"><el-select v-model="form.currency" filterable allow-create default-first-option><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" / > < / el-select > < / el-form-item > < / div > < / section >
< section class = "form-section" > < h3 > 生效范围 < / h3 > < div class = "form-grid" > < el-form-item label = "生效时间" prop = "effectiveFrom" > < el-date-picker v-model = "form.effectiveFrom" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="选择生效时间" /></el-form-item><el-form-item label="失效时间"><el-date-picker v-model="form.effectiveTo" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="不填表示长期有效" /></el-form-item><el-form-item label="备注" class="wide"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show -word -limit / > < / el-form-item > < / div > < / section >
< / el-form >
< / AppDrawer >
< / div >
< / template >
< style scoped >
. billing - rules - panel { min - width : 0 ; }
. billing - heading { align - items : center ; }
. heading - actions , . row - actions { display : flex ; align - items : center ; gap : 8 px ; }
. heading - actions : deep ( . el - button ) , . row - actions : deep ( . el - button ) { margin - left : 0 ; }
. billing - state { display : flex ; min - height : 220 px ; align - items : center ; justify - content : center ; flex - direction : column ; gap : 8 px ; color : # 8797 b2 ; text - align : center ; }
. billing - state strong { color : # 475467 ; font - size : 13 px ; } . billing - state p { max - width : 520 px ; margin : 0 ; color : var ( -- cc - muted ) ; font - size : 12 px ; line - height : 1.7 ; }
. billing - error { display : flex ; align - items : center ; justify - content : space - between ; gap : 18 px ; padding : 16 px ; border - radius : 12 px ; background : # fff4f3 ; } . billing - error strong { color : # 7 a271a ; } . billing - error p { margin : 5 px 0 ; color : # b42318 ; font - size : 12 px ; }
. primary - cell { display : flex ; flex - direction : column ; gap : 3 px ; } . primary - cell small , . subline { display : block ; margin - top : 3 px ; color : var ( -- cc - muted ) ; font - size : 11 px ; }
. rule - detail { display : grid ; gap : 24 px ; } . rule - detail section + section , . form - section + . form - section { padding - top : 22 px ; border - top : 1 px solid var ( -- cc - border ) ; } . rule - detail h3 , . form - section h3 { margin : 0 0 14 px ; color : var ( -- cc - text ) ; font - size : 15 px ; }
. detail - grid { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; gap : 16 px 24 px ; margin : 0 ; } . detail - grid > div { min - width : 0 ; } . detail - grid dt { margin - bottom : 5 px ; color : var ( -- cc - muted ) ; font - size : 12 px ; } . detail - grid dd { margin : 0 ; color : var ( -- cc - text ) ; line - height : 1.55 ; overflow - wrap : anywhere ; } . detail - wide { grid - column : 1 / - 1 ; }
. rule - form { display : grid ; gap : 24 px ; } . customer - context { display : grid ; grid - template - columns : auto minmax ( 0 , 1 fr ) auto ; align - items : center ; gap : 12 px ; padding : 14 px 16 px ; border - radius : 12 px ; background : # f5f7ff ; } . customer - context span , . customer - context small { color : var ( -- cc - muted ) ; font - size : 12 px ; } . customer - context strong { color : # 25324 a ; } . form - grid { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; gap : 2 px 20 px ; } . wide { grid - column : 1 / - 1 ; } . form - grid : deep ( . el - select ) , . form - grid : deep ( . el - input - number ) , . form - grid : deep ( . el - date - editor ) { width : 100 % ; }
@ media ( max - width : 760 px ) { . billing - heading { align - items : stretch ; flex - direction : column ; } . heading - actions { width : 100 % ; } . heading - actions : deep ( . el - button ) { min - height : 44 px ; flex : 1 ; } . form - grid , . detail - grid { grid - template - columns : 1 fr ; } . wide , . detail - wide { grid - column : auto ; } . billing - error { align - items : stretch ; flex - direction : column ; } }
@ media ( max - width : 480 px ) { . customer - context { grid - template - columns : 1 fr ; gap : 4 px ; } . row - actions { flex - wrap : wrap ; } }
< / style >