2026-08-10 15:36:53 +08:00
|
|
|
|
<script setup lang="ts">
|
2026-09-15 09:58:15 +08:00
|
|
|
|
import '@/styles/adminDensity.css'
|
2026-08-10 15:36:53 +08:00
|
|
|
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|
|
|
|
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
2026-09-15 09:58:15 +08:00
|
|
|
|
import { Plus, Save } 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'
|
2026-08-19 09:14:35 +08:00
|
|
|
|
import { categoryLabels, type SupplierCategory } from '@/modules/suppliers'
|
2026-08-10 15:36:53 +08:00
|
|
|
|
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: '',
|
2026-08-19 09:14:35 +08:00
|
|
|
|
supplierCategory: null,
|
2026-08-10 15:36:53 +08:00
|
|
|
|
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' }],
|
|
|
|
|
|
}
|
2026-08-19 09:14:35 +08:00
|
|
|
|
const hasPayableDirection = computed(() => form.pickupPayable || form.warehousePayable)
|
2026-08-10 15:36:53 +08:00
|
|
|
|
|
|
|
|
|
|
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' },
|
2026-08-19 09:14:35 +08:00
|
|
|
|
{ key: 'supplierCategory', label: '供应商类别', width: 120, slotName: 'supplierCategory' },
|
2026-08-10 15:36:53 +08:00
|
|
|
|
{ key: 'allocationFee', label: '分摊费', width: 100, slotName: 'allocationFee' },
|
2026-08-12 12:19:02 +08:00
|
|
|
|
{ key: 'description', label: '说明', minWidth: 220, slotName: 'description', defaultVisible: false },
|
2026-08-17 19:52:04 +08:00
|
|
|
|
{ key: 'createdByName', label: '创建人', minWidth: 120, formatter: (row: PlatformFeeItemResource) => row.createdByName || '—' },
|
|
|
|
|
|
{ key: 'createdAt', label: '创建日期', minWidth: 155, formatter: (row: PlatformFeeItemResource) => formatTime(row.createdAt) },
|
|
|
|
|
|
{ key: 'updatedByName', label: '更新人', minWidth: 120, formatter: (row: PlatformFeeItemResource) => row.updatedByName || '—' },
|
|
|
|
|
|
{ key: 'updatedAt', label: '更新日期', minWidth: 155, formatter: (row: PlatformFeeItemResource) => formatTime(row.updatedAt) },
|
2026-08-10 15:36:53 +08:00
|
|
|
|
{ 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,
|
2026-08-19 09:14:35 +08:00
|
|
|
|
supplierCategory: item.supplierCategory,
|
2026-08-10 15:36:53 +08:00
|
|
|
|
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(),
|
2026-08-19 09:14:35 +08:00
|
|
|
|
supplierCategory: hasPayableDirection.value ? form.supplierCategory : null,
|
2026-08-10 15:36:53 +08:00
|
|
|
|
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
|
2026-08-19 09:14:35 +08:00
|
|
|
|
if (hasPayableDirection.value && !form.supplierCategory) {
|
|
|
|
|
|
ElMessage.warning('启用应付方向时必须选择供应商类别')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-08-10 15:36:53 +08:00
|
|
|
|
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-09-15 09:58:15 +08:00
|
|
|
|
<AdminPage as="div" class="fee-items-page admin-density" 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="费用项目管理">
|
2026-08-10 15:36:53 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-09-30 14:01:58 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError"
|
2026-08-10 15:36:53 +08:00
|
|
|
|
v-model:page="page"
|
|
|
|
|
|
v-model:page-size="pageSize"
|
|
|
|
|
|
:data="rows"
|
|
|
|
|
|
:columns="columns"
|
|
|
|
|
|
:loading="loading"
|
|
|
|
|
|
:total="total"
|
|
|
|
|
|
:page-sizes="[20, 50, 100]"
|
2026-08-12 18:00:26 +08:00
|
|
|
|
border
|
2026-08-10 15:36:53 +08:00
|
|
|
|
preference-key="fee-items-table"
|
|
|
|
|
|
aria-label="费用项目列表"
|
|
|
|
|
|
row-key="id"
|
|
|
|
|
|
empty-text="暂无符合条件的费用项目"
|
|
|
|
|
|
>
|
2026-09-30 14:01:58 +08:00
|
|
|
|
<template #toolbar-start><h1>费用项目</h1></template>
|
|
|
|
|
|
<template #filters>
|
|
|
|
|
|
<QueryToolbar
|
|
|
|
|
|
v-model="filters"
|
|
|
|
|
|
ariaLabel="费用项目筛选"
|
|
|
|
|
|
:fields="queryFields"
|
|
|
|
|
|
:loading="loading"
|
|
|
|
|
|
preference-key="fee-items-query"
|
|
|
|
|
|
@search="applyFilters"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template #notices><template v-if="loadError">
|
|
|
|
|
|
<LoadErrorState
|
|
|
|
|
|
title="费用项目加载失败"
|
|
|
|
|
|
:message="loadError.message"
|
|
|
|
|
|
:loading="loading"
|
|
|
|
|
|
@retry="loadFeeItems"
|
|
|
|
|
|
>
|
|
|
|
|
|
<template #details><ApiErrorMeta :details="loadError" /></template>
|
|
|
|
|
|
</LoadErrorState>
|
|
|
|
|
|
</template></template>
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 15:36:53 +08:00
|
|
|
|
<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>
|
2026-09-30 14:01:58 +08:00
|
|
|
|
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions">
|
2026-09-15 09:58:15 +08:00
|
|
|
|
<div v-if="access.can('firstleg:fee-item:edit') || access.can('firstleg:fee-item:delete')" class="table-actions density-actions">
|
|
|
|
|
|
<el-button data-tone="neutral" class="density-action" v-if="access.can('firstleg:fee-item:edit')" size="small" :disabled="loading || editorLoading" @click="openEdit(row as PlatformFeeItemResource)">编辑</el-button>
|
|
|
|
|
|
<el-button class="density-action"
|
2026-08-10 15:36:53 +08:00
|
|
|
|
v-if="access.can('firstleg:fee-item:edit')"
|
2026-08-12 17:45:49 +08:00
|
|
|
|
size="small"
|
2026-09-15 09:58:15 +08:00
|
|
|
|
|
|
|
|
|
|
:data-tone="(row as PlatformFeeItemResource).status === 'ACTIVE' ? 'danger' : 'success'"
|
2026-08-10 15:36:53 +08:00
|
|
|
|
:loading="statusLoadingIds.has(String((row as PlatformFeeItemResource).id))"
|
|
|
|
|
|
:disabled="loading"
|
|
|
|
|
|
@click="requestStatusChange(row as PlatformFeeItemResource)"
|
2026-09-15 09:58:15 +08:00
|
|
|
|
>{{ (row as PlatformFeeItemResource).status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
|
|
|
|
|
|
<el-button data-tone="danger" class="density-action" v-if="access.can('firstleg:fee-item:delete')" size="small" :disabled="loading" @click="requestDelete(row as PlatformFeeItemResource)">删除</el-button>
|
2026-08-10 15:36:53 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<span v-else class="muted-value">仅查看</span>
|
2026-09-30 14:01:58 +08:00
|
|
|
|
</div></template>
|
2026-09-15 15:01:01 +08:00
|
|
|
|
<template #feeItem="{ row }"><div class="fee-item-name"><strong>{{ (row as PlatformFeeItemResource).feeTypeName }}</strong>
|
|
|
|
|
|
<!-- <small>{{ (row as PlatformFeeItemResource).feeCode || '未分配编码' }}</small> -->
|
|
|
|
|
|
</div></template>
|
2026-08-10 15:36:53 +08:00
|
|
|
|
<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>
|
2026-08-19 09:14:35 +08:00
|
|
|
|
<template #supplierCategory="{ row }">{{ (row as PlatformFeeItemResource).supplierCategory ? categoryLabels[(row as PlatformFeeItemResource).supplierCategory as SupplierCategory] : '未配置' }}</template>
|
2026-08-10 15:36:53 +08:00
|
|
|
|
<template #allocationFee="{ row }">{{ (row as PlatformFeeItemResource).allocationFee ? '是' : '否' }}</template>
|
|
|
|
|
|
<template #description="{ row }"><span class="description-cell">{{ (row as PlatformFeeItemResource).description || '—' }}</span></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-09-30 14:01:58 +08:00
|
|
|
|
</DataTable></section>
|
2026-08-10 16:24:54 +08:00
|
|
|
|
</ListPageLayout>
|
2026-08-10 15:36:53 +08:00
|
|
|
|
|
2026-09-15 09:58:15 +08:00
|
|
|
|
<AppDrawer class="admin-density admin-density-drawer"
|
2026-08-10 15:36:53 +08:00
|
|
|
|
v-model="editorOpen"
|
|
|
|
|
|
:title="editorTitle"
|
|
|
|
|
|
size="min(620px, 94vw)"
|
|
|
|
|
|
:loading="editorLoading"
|
|
|
|
|
|
:confirm-loading="saving"
|
|
|
|
|
|
>
|
2026-09-15 09:58:15 +08:00
|
|
|
|
<el-form class="density-form" ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving || editorLoading">
|
|
|
|
|
|
<FormSection class="density-form-section" title="基本信息" description="费用项目为平台公共主数据,不按客户单独维护。">
|
2026-08-10 16:24:54 +08:00
|
|
|
|
<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>
|
2026-09-15 09:58:15 +08:00
|
|
|
|
<FormSection class="density-form-section" 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-19 09:14:35 +08:00
|
|
|
|
<el-form-item v-if="hasPayableDirection" label="供应商类别" required>
|
|
|
|
|
|
<el-select v-model="form.supplierCategory" placeholder="请选择 Warehouse 供应商类别">
|
|
|
|
|
|
<el-option v-for="(label, value) in categoryLabels" :key="value" :label="label" :value="value" />
|
|
|
|
|
|
</el-select>
|
|
|
|
|
|
</el-form-item>
|
2026-08-10 16:24:54 +08:00
|
|
|
|
</FormSection>
|
2026-09-15 09:58:15 +08:00
|
|
|
|
<FormSection class="density-form-section" title="适用说明" :bordered="false">
|
2026-08-10 16:24:54 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-09-15 09:58:15 +08:00
|
|
|
|
<AppDialog class="admin-density-dialog"
|
2026-08-10 15:36:53 +08:00
|
|
|
|
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>
|
|
|
|
|
|
|
2026-09-15 09:58:15 +08:00
|
|
|
|
<AppDialog class="admin-density-dialog"
|
2026-08-10 15:36:53 +08:00
|
|
|
|
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-09-15 09:58:15 +08:00
|
|
|
|
.fee-item-name { display: flex; min-width: 0; flex-direction: column; gap: var(--admin-space-1); }
|
2026-08-10 15:36:53 +08:00
|
|
|
|
.fee-item-name strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
2026-09-15 09:58:15 +08:00
|
|
|
|
.fee-item-name small { color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
2026-08-10 15:36:53 +08:00
|
|
|
|
.description-cell { display: -webkit-box; overflow: hidden; line-height: 1.5; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
|
2026-09-15 09:58:15 +08:00
|
|
|
|
.direction-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-5); margin-bottom: var(--admin-space-5); }
|
|
|
|
|
|
.direction-grid fieldset { min-width: 0; margin: 0; padding: var(--admin-space-5); border: 1px solid var(--el-border-color); border-radius: var(--admin-radius); }
|
|
|
|
|
|
.direction-grid legend { padding: 0 5px; color: var(--el-text-color-secondary); font-size: var(--admin-font-body); }
|
2026-08-10 15:36:53 +08:00
|
|
|
|
.delete-message { margin: 0; color: var(--el-text-color-regular); line-height: 1.7; overflow-wrap: anywhere; }
|
|
|
|
|
|
@media (max-width: 720px) {
|
|
|
|
|
|
.direction-grid, .form-grid.two-cols { grid-template-columns: 1fr; }
|
|
|
|
|
|
.table-actions { flex-wrap: wrap; }
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|