2026-08-10 15:36:53 +08:00
< script setup lang = "ts" >
import { computed , onMounted , reactive , ref , watch } from 'vue'
import { ElMessage , type FormInstance , type FormRules } from 'element-plus'
2026-08-10 16:24:54 +08:00
import { Pencil , Plus , Power , Save , Trash2 } from '@lucide/vue'
2026-08-10 15:36:53 +08:00
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import {
changePlatformFeeItemStatus ,
createPlatformFeeItem ,
deletePlatformFeeItem ,
getPlatformFeeItem ,
listPlatformFeeItems ,
updatePlatformFeeItem ,
type FeeItemStatus ,
type FeeItemTradeScope ,
type PlatformFeeItemResource ,
type SavePlatformFeeItemRequest ,
} from '@/modules/fee-items/api/platformFeeItems'
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
2026-08-10 16:24:54 +08:00
import AdminPage from '@/shared/components/AdminPage.vue'
2026-08-10 15:36:53 +08:00
import AppDialog from '@/shared/components/AppDialog.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.vue'
2026-08-10 16:24:54 +08:00
import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
2026-08-10 15:36:53 +08:00
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar , { type QueryField , type QueryModel } from '@/shared/components/QueryToolbar.vue'
type FeeItemFilters = QueryModel & {
keyword : string
category : string
tradeScope : FeeItemTradeScope | ''
status : FeeItemStatus | ''
}
type FeeItemForm = SavePlatformFeeItemRequest & { id : string }
const access = useAccessPreviewStore ()
const rows = ref < PlatformFeeItemResource [] >([])
const loading = ref ( false )
const loadError = ref < ApiErrorDetails | null >( null )
const page = ref ( 1 )
const pageSize = ref ( 20 )
const total = ref ( 0 )
const filters = ref < FeeItemFilters >( emptyFilters ())
const appliedFilters = ref < FeeItemFilters >( emptyFilters ())
const editorOpen = ref ( false )
const editorLoading = ref ( false )
const saving = ref ( false )
const formRef = ref < FormInstance >()
const statusLoadingIds = ref ( new Set < string >())
const statusOpen = ref ( false )
const statusTarget = ref < PlatformFeeItemResource | null >( null )
const statusSaving = ref ( false )
const deleteOpen = ref ( false )
const deleteTarget = ref < PlatformFeeItemResource | null >( null )
const deleting = ref ( false )
let loadSequence = 0
let editorSequence = 0
function emptyFilters () : FeeItemFilters {
return { keyword : '' , category : '' , tradeScope : '' , status : '' }
}
function emptyForm () : FeeItemForm {
return {
id : '' ,
category : '' ,
tradeScope : 'DOMESTIC' ,
feeTypeName : '' ,
pickupPayable : false ,
pickupReceivable : false ,
warehousePayable : false ,
warehouseReceivable : false ,
allocationFee : false ,
description : null ,
}
}
const form = reactive < FeeItemForm >( emptyForm ())
const rules : FormRules < FeeItemForm > = {
category : [{ required : true , message : '请输入费用分类' , trigger : 'blur' }],
tradeScope : [{ required : true , message : '请选择贸易范围' , trigger : 'change' }],
feeTypeName : [{ required : true , message : '请输入费用名称' , trigger : 'blur' }],
}
const canView = computed (() => access . can ( 'firstleg:fee-item:view' ))
const editorTitle = computed (() => form . id ? '编辑费用项目' : '新增费用项目' )
const editorPermission = computed (() => form . id ? 'firstleg:fee-item:edit' : 'firstleg:fee-item:create' )
const { captureBaseline } = useUnsavedWorkRegistration ({
key : 'fee-item-editor' ,
label : editorTitle ,
isOpen : editorOpen ,
model : form ,
requiredPermission : editorPermission ,
})
const queryFields = computed < QueryField [] >(() => [
{ key : 'keyword' , type : 'input' , width : 210 , label : '费用名称' , placeholder : '请输入费用名称' , props : { 'aria-label' : '费用名称' , maxlength : 128 } },
{ key : 'category' , type : 'input' , width : 190 , label : '费用分类' , placeholder : '请输入费用分类' , props : { 'aria-label' : '费用分类' , maxlength : 64 } },
{
key : 'tradeScope' , type : 'select' , width : 160 , label : '贸易范围' , placeholder : '全部范围' , props : { 'aria-label' : '贸易范围' },
options : [{ label : '国内' , value : 'DOMESTIC' }, { label : '国际' , value : 'INTERNATIONAL' }],
},
{
key : 'status' , type : 'select' , width : 150 , label : '状态' , placeholder : '全部状态' , props : { 'aria-label' : '状态' },
options : [{ label : '启用' , value : 'ACTIVE' }, { label : '停用' , value : 'DISABLED' }],
},
])
const columns : DataTableColumn [] = [
{ key : 'actions' , label : '操作' , width : 250 , fixed : 'left' , slotName : 'actions' },
{ key : 'feeTypeName' , label : '费用项目' , minWidth : 210 , slotName : 'feeItem' },
{ key : 'category' , prop : 'category' , label : '费用分类' , minWidth : 150 },
{ key : 'tradeScope' , label : '贸易范围' , width : 110 , slotName : 'tradeScope' },
{ key : 'pickup' , label : '提货' , minWidth : 160 , slotName : 'pickup' },
{ key : 'warehouse' , label : '仓库' , minWidth : 160 , slotName : 'warehouse' },
{ key : 'allocationFee' , label : '分摊费' , width : 100 , slotName : 'allocationFee' },
{ key : 'description' , label : '说明' , minWidth : 220 , slotName : 'description' },
{ key : 'updatedAt' , label : '更新时间' , minWidth : 155 , slotName : 'updatedAt' },
{ key : 'status' , label : '状态' , width : 100 , slotName : 'status' },
]
function optionalText ( value : string | null ) {
return value ? . trim () || null
}
function formatTime ( value ?: string | null ) {
return value ? . replace ( 'T' , ' ' ). slice ( 0 , 16 ) || '—'
}
function directionText ( payable : boolean , receivable : boolean ) {
if ( payable && receivable ) return '应付、应收'
if ( payable ) return '应付'
if ( receivable ) return '应收'
return '不适用'
}
async function loadFeeItems () {
if ( ! canView . value ) {
rows . value = []
total . value = 0
loadError . value = null
return
}
const sequence = ++ loadSequence
loading . value = true
loadError . value = null
try {
const result = await listPlatformFeeItems ({
keyword : appliedFilters . value . keyword ,
category : appliedFilters . value . category ,
tradeScope : appliedFilters . value . tradeScope ,
status : appliedFilters . value . status ,
page : page . value ,
size : pageSize . value ,
})
if ( sequence !== loadSequence ) return
rows . value = result . records
total . value = result . total
} catch ( error ) {
if ( sequence !== loadSequence ) return
rows . value = []
total . value = 0
loadError . value = getApiErrorDetails ( error , '费用项目加载失败,请稍后重试' )
} finally {
if ( sequence === loadSequence ) loading . value = false
}
}
function applyFilters ( model : QueryModel ) {
appliedFilters . value = {
keyword : String ( model . keyword || '' ),
category : String ( model . category || '' ),
tradeScope : ( model . tradeScope || '' ) as FeeItemTradeScope | '' ,
status : ( model . status || '' ) as FeeItemStatus | '' ,
}
if ( page . value === 1 ) void loadFeeItems ()
else page . value = 1
}
function openCreate () {
editorSequence += 1
Object . assign ( form , emptyForm ())
captureBaseline ()
editorOpen . value = true
void formRef . value ? . clearValidate ()
}
function assignForm ( item : PlatformFeeItemResource ) {
Object . assign ( form , {
id : String ( item . id ),
category : item . category ,
tradeScope : item . tradeScope ,
feeTypeName : item . feeTypeName ,
pickupPayable : item . pickupPayable ,
pickupReceivable : item . pickupReceivable ,
warehousePayable : item . warehousePayable ,
warehouseReceivable : item . warehouseReceivable ,
allocationFee : item . allocationFee ,
description : item . description ,
})
captureBaseline ()
editorOpen . value = true
void formRef . value ? . clearValidate ()
}
async function openEdit ( item : PlatformFeeItemResource ) {
const sequence = ++ editorSequence
editorLoading . value = true
try {
const detail = await getPlatformFeeItem ( item . id )
if ( sequence === editorSequence ) assignForm ( detail )
} catch ( error ) {
if ( sequence !== editorSequence ) return
const details = getApiErrorDetails ( error , '费用项目详情加载失败,请稍后重试' )
ElMessage . error ( details . requestId ? ` ${ details . message } ( requestId: ${ details . requestId } ) ` : details . message )
} finally {
if ( sequence === editorSequence ) editorLoading . value = false
}
}
function buildPayload () : SavePlatformFeeItemRequest {
return {
category : form . category . trim (),
tradeScope : form . tradeScope ,
feeTypeName : form . feeTypeName . trim (),
pickupPayable : form . pickupPayable ,
pickupReceivable : form . pickupReceivable ,
warehousePayable : form . warehousePayable ,
warehouseReceivable : form . warehouseReceivable ,
allocationFee : form . allocationFee ,
description : optionalText ( form . description ),
}
}
async function saveFeeItem () {
const valid = await formRef . value ? . validate (). catch (() => false )
if ( ! valid ) return
saving . value = true
try {
if ( form . id ) await updatePlatformFeeItem ( form . id , buildPayload ())
else await createPlatformFeeItem ( buildPayload ())
editorOpen . value = false
ElMessage . success ( form . id ? '费用项目已更新' : '费用项目已创建' )
await loadFeeItems ()
} catch ( error ) {
const details = getApiErrorDetails ( error , '费用项目保存失败,请检查后重试' )
ElMessage . error ( details . requestId ? ` ${ details . message } ( requestId: ${ details . requestId } ) ` : details . message )
} finally {
saving . value = false
}
}
function requestStatusChange ( item : PlatformFeeItemResource ) {
statusTarget . value = item
statusOpen . value = true
}
async function confirmStatusChange () {
const item = statusTarget . value
if ( ! item ) return
const nextStatus : FeeItemStatus = item . status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'
const action = nextStatus === 'ACTIVE' ? '启用' : '停用'
const key = String ( item . id )
statusLoadingIds . value = new Set ( statusLoadingIds . value ). add ( key )
statusSaving . value = true
try {
await changePlatformFeeItemStatus ( item . id , nextStatus )
ElMessage . success ( `已 ${ action } “ ${ item . feeTypeName } ”` )
statusOpen . value = false
statusTarget . value = null
await loadFeeItems ()
} catch ( error ) {
const details = getApiErrorDetails ( error , ` ${ action } 费用项目失败` )
ElMessage . error ( details . requestId ? ` ${ details . message } ( requestId: ${ details . requestId } ) ` : details . message )
} finally {
const pending = new Set ( statusLoadingIds . value )
pending . delete ( key )
statusLoadingIds . value = pending
statusSaving . value = false
}
}
function requestDelete ( item : PlatformFeeItemResource ) {
deleteTarget . value = item
deleteOpen . value = true
}
async function confirmDelete () {
if ( ! deleteTarget . value ) return
deleting . value = true
try {
await deletePlatformFeeItem ( deleteTarget . value . id )
ElMessage . success ( `已删除“ ${ deleteTarget . value . feeTypeName } ”` )
deleteOpen . value = false
deleteTarget . value = null
if ( rows . value . length === 1 && page . value > 1 ) page . value -= 1
else await loadFeeItems ()
} catch ( error ) {
const details = getApiErrorDetails ( error , '费用项目删除失败;已被业务使用的项目不能删除' )
ElMessage . error ( details . requestId ? ` ${ details . message } ( requestId: ${ details . requestId } ) ` : details . message )
} finally {
deleting . value = false
}
}
watch ([ page , pageSize ], () => void loadFeeItems ())
watch (
() => access . can ( editorPermission . value ),
( allowed ) => {
if ( ! allowed && editorOpen . value ) editorOpen . value = false
},
{ flush : 'sync' },
)
onMounted (() => {
if ( canView . value ) void loadFeeItems ()
})
</ script >
< template >
2026-08-10 16:24:54 +08:00
< AdminPage as = "div" class = "fee-items-page" spacing = "normal" >
2026-08-10 15:36:53 +08:00
< el-result v-if = "!canView" icon="warning" title="无权查看费用项目" sub-title="请联系管理员开通费用项目查看权限" />
2026-08-10 16:24:54 +08:00
< ListPageLayout v-else aria -label =" 费用项目管理 " >
< template # toolbar >
< QueryToolbar
v-model = "filters"
class = "surface-panel"
ariaLabel = "费用项目筛选"
:fields = "queryFields"
preference -key =" fee -items-query "
@search ="applyFilters"
/>
</ template >
2026-08-10 15:36:53 +08:00
2026-08-10 16:24:54 +08:00
< template v-if = "loadError" # state >
< LoadErrorState
title = "费用项目加载失败"
:message = "loadError.message"
:loading = "loading"
@retry ="loadFeeItems"
>
< template # details >< ApiErrorMeta :details = "loadError" /></ template >
</ LoadErrorState >
</ template >
2026-08-10 15:36:53 +08:00
2026-08-10 16:24:54 +08:00
< section v-if = "!loadError" class="surface-panel table-panel" >
2026-08-10 15:36:53 +08:00
< DataTable
v -model:page = "page"
v -model:page-size = "pageSize"
:data = "rows"
:columns = "columns"
:loading = "loading"
:total = "total"
: page -sizes ="[ 20 , 50 , 100 ]"
preference -key =" fee -items-table "
aria -label =" 费用项目列表 "
row -key =" id "
empty -text =" 暂无符合条件的费用项目 "
>
< template # toolbar -actions >
< el-button
v-if = "access.can('firstleg:fee-item:create')"
type = "primary"
:disabled = "loading"
@click ="openCreate"
>< Plus :size = "16" /> 新增费用项目 </ el-button >
</ template >
< template # actions = "{ row }" >
< div v-if = "access.can('firstleg:fee-item:edit') || access.can('firstleg:fee-item:delete')" class="table-actions" >
< el-button v-if = "access.can('firstleg:fee-item:edit')" link type="primary" :disabled="loading || editorLoading" @click="openEdit(row as PlatformFeeItemResource)"><Pencil :size="15" /> 编辑 </ el-button >
< el-button
v-if = "access.can('firstleg:fee-item:edit')"
link
: type = "(row as PlatformFeeItemResource).status === 'ACTIVE' ? 'warning' : 'success'"
: loading = "statusLoadingIds.has(String((row as PlatformFeeItemResource).id))"
:disabled = "loading"
@click ="requestStatusChange(row as PlatformFeeItemResource)"
>< Power :size = "15" />{{ ( row as PlatformFeeItemResource ). status === 'ACTIVE' ? '停用' : '启用' }}</ el-button >
< el-button v-if = "access.can('firstleg:fee-item:delete')" link type="danger" :disabled="loading" @click="requestDelete(row as PlatformFeeItemResource)"><Trash2 :size="15" /> 删除 </ el-button >
</ div >
< span v-else class = "muted-value" > 仅查看 </ span >
</ template >
< template # feeItem = "{ row }" >< div class = "fee-item-name" >< strong >{{ ( row as PlatformFeeItemResource ). feeTypeName }}</ strong >< small >{{ ( row as PlatformFeeItemResource ). feeCode || '未分配编码' }}</ small ></ div ></ template >
< template # tradeScope = "{ row }" >< el-tag effect = "plain" >{{ ( row as PlatformFeeItemResource ). tradeScope === 'DOMESTIC' ? '国内' : '国际' }}</ el-tag ></ template >
< template # pickup = "{ row }" >{{ directionText (( row as PlatformFeeItemResource ). pickupPayable , ( row as PlatformFeeItemResource ). pickupReceivable ) }}</ template >
< template # warehouse = "{ row }" >{{ directionText (( row as PlatformFeeItemResource ). warehousePayable , ( row as PlatformFeeItemResource ). warehouseReceivable ) }}</ template >
< template # allocationFee = "{ row }" >{{ ( row as PlatformFeeItemResource ). allocationFee ? '是' : '否' }}</ template >
< template # description = "{ row }" >< span class = "description-cell" >{{ ( row as PlatformFeeItemResource ). description || '—' }}</ span ></ template >
< template # updatedAt = "{ row }" >{{ formatTime (( row as PlatformFeeItemResource ). updatedAt ) }}</ template >
< template # status = "{ row }" >< el-tag : type = "(row as PlatformFeeItemResource).status === 'ACTIVE' ? 'success' : 'info'" >{{ ( row as PlatformFeeItemResource ). status === 'ACTIVE' ? '启用' : '停用' }}</ el-tag ></ template >
< template # empty >
< el-empty v-if = "!loading" description="暂无符合条件的费用项目" >
< el-button v-if = "access.can('firstleg:fee-item:create')" type="primary" @click="openCreate"><Plus :size="16" /> 新增第一项费用 </ el-button >
</ el-empty >
</ template >
2026-08-10 16:24:54 +08:00
< template # pagination -left > 平台公共主数据 · 共 {{ total }} 项 </ template >
2026-08-10 15:36:53 +08:00
</ DataTable >
</ section >
2026-08-10 16:24:54 +08:00
</ ListPageLayout >
2026-08-10 15:36:53 +08:00
< AppDrawer
v-model = "editorOpen"
:title = "editorTitle"
size = "min(620px, 94vw)"
:loading = "editorLoading"
:confirm-loading = "saving"
>
< el-form ref = "formRef" :model = "form" :rules = "rules" label -position =" top " : disabled = "saving || editorLoading" >
2026-08-10 16:24:54 +08:00
< FormSection title = "基本信息" description = "费用项目为平台公共主数据,不按客户单独维护。" >
< div class = "form-grid two-cols" >
< el-form-item label = "费用名称" prop = "feeTypeName" required >< el-input v-model = "form.feeTypeName" maxlength="128" placeholder="请输入费用名称" /></ el-form-item >
< el-form-item label = "费用分类" prop = "category" required >< el-input v-model = "form.category" maxlength="64" placeholder="例如 仓储成本" /></ el-form-item >
</ div >
< el-form-item label = "贸易范围" prop = "tradeScope" required >
< el-segmented v-model = "form.tradeScope" :options="[{ label: '国内', value: 'DOMESTIC' }, { label: '国际', value: 'INTERNATIONAL' }]" />
</ el-form-item >
</ FormSection >
< FormSection title = "费用方向" description = "选择费用在提货和仓库环节的应收、应付属性。" >
2026-08-10 15:36:53 +08:00
< div class = "direction-grid" >
< fieldset >< legend > 提货环节 </ legend >< el-checkbox v-model = "form.pickupPayable">应付</el-checkbox><el-checkbox v-model="form.pickupReceivable" > 应收 </ el -checkbox ></ fieldset >
< fieldset >< legend > 仓库环节 </ legend >< el-checkbox v-model = "form.warehousePayable">应付</el-checkbox><el-checkbox v-model="form.warehouseReceivable" > 应收 </ el -checkbox ></ fieldset >
</ div >
< el-checkbox v-model = "form.allocationFee" > 作为分摊费用 </ el -checkbox >
2026-08-10 16:24:54 +08:00
</ FormSection >
< FormSection title = "适用说明" :bordered = "false" >
< el-form-item label = "说明" >< el-input v-model = "form.description" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入适用场景或核算说明" /></ el-form-item >
</ FormSection >
2026-08-10 15:36:53 +08:00
</ el-form >
2026-08-10 16:24:54 +08:00
< template # footer >
< div class = "drawer-footer" >
< el-button :disabled = "saving" @click ="editorOpen = false" > 取消 </ el -button >
< el-button type = "primary" :loading = "saving" :disabled = "editorLoading" @click ="saveFeeItem" >< Save :size = "16" /> 保存费用项目 </ el-button >
</ div >
</ template >
2026-08-10 15:36:53 +08:00
</ AppDrawer >
< AppDialog
v-model = "statusOpen"
: title = "statusTarget?.status === 'ACTIVE' ? '停用费用项目' : '启用费用项目'"
: confirm -text =" statusTarget ? .status === ' ACTIVE ' ? ' 确认停用 ' : ' 确认启用 '"
: confirm -type =" statusTarget ? .status === ' ACTIVE ' ? ' warning ' : ' success '"
:confirm-loading = "statusSaving"
@confirm ="confirmStatusChange"
>
< p class = "delete-message" >
确定 {{ statusTarget ? . status === 'ACTIVE' ? '停用' : '启用' }} “ < strong >{{ statusTarget ? . feeTypeName }}</ strong > ” 吗 ?
< template v-if = "statusTarget?.status === 'ACTIVE'" > 停用后该项目不能再用于新的费用记录 。 </ template >
</ p >
</ AppDialog >
< AppDialog
v-model = "deleteOpen"
title = "删除费用项目"
confirm -text =" 确认删除 "
confirm -type =" danger "
:confirm-loading = "deleting"
@confirm ="confirmDelete"
>
< p class = "delete-message" > 确定删除 “ < strong >{{ deleteTarget ? . feeTypeName }}</ strong > ” 吗 ? 删除后无法恢复 ; 已被业务单据使用的项目将由服务端拒绝删除 。 </ p >
</ AppDialog >
2026-08-10 16:24:54 +08:00
</ AdminPage >
2026-08-10 15:36:53 +08:00
</ template >
< style scoped >
2026-08-10 16:24:54 +08:00
. fee - items - page { min - width : 0 ; }
2026-08-10 15:36:53 +08:00
. fee - item - name { display : flex ; min - width : 0 ; flex - direction : column ; gap : 4 px ; }
. fee - item - name strong { overflow : hidden ; text - overflow : ellipsis ; white - space : nowrap ; }
. fee - item - name small { color : var ( -- cc - muted ); font - size : 11 px ; }
. description - cell { display : - webkit - box ; overflow : hidden ; line - height : 1.5 ; - webkit - box - orient : vertical ; - webkit - line - clamp : 2 ; }
. direction - grid { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr )); gap : 12 px ; margin - bottom : 12 px ; }
. direction - grid fieldset { min - width : 0 ; margin : 0 ; padding : 12 px ; border : 1 px solid var ( -- el - border - color ); border - radius : 6 px ; }
. direction - grid legend { padding : 0 5 px ; color : var ( -- el - text - color - secondary ); font - size : 13 px ; }
. delete - message { margin : 0 ; color : var ( -- el - text - color - regular ); line - height : 1.7 ; overflow - wrap : anywhere ; }
@ media ( max - width : 720 px ) {
. direction - grid , . form - grid . two - cols { grid - template - columns : 1 fr ; }
. table - actions { flex - wrap : wrap ; }
}
</ style >