Files
admin-web/src/modules/fee-items/pages/FeeItemsView.vue
T
2026-09-30 14:01:58 +08:00

510 lines
23 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 { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { Plus, Save } from '@lucide/vue'
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 { categoryLabels, type SupplierCategory } from '@/modules/suppliers'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
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: '',
supplierCategory: null,
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 hasPayableDirection = computed(() => form.pickupPayable || form.warehousePayable)
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: 'supplierCategory', label: '供应商类别', width: 120, slotName: 'supplierCategory' },
{ key: 'allocationFee', label: '分摊费', width: 100, slotName: 'allocationFee' },
{ key: 'description', label: '说明', minWidth: 220, slotName: 'description', defaultVisible: false },
{ 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) },
{ 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,
supplierCategory: item.supplierCategory,
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(),
supplierCategory: hasPayableDirection.value ? form.supplierCategory : null,
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
if (hasPayableDirection.value && !form.supplierCategory) {
ElMessage.warning('启用应付方向时必须选择供应商类别')
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>
<AdminPage as="div" class="fee-items-page admin-density" spacing="normal">
<el-result v-if="!canView" icon="warning" title="无权查看费用项目" sub-title="请联系管理员开通费用项目查看权限" />
<ListPageLayout v-else aria-label="费用项目管理">
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError"
v-model:page="page"
v-model:page-size="pageSize"
:data="rows"
:columns="columns"
:loading="loading"
:total="total"
:page-sizes="[20, 50, 100]"
border
preference-key="fee-items-table"
aria-label="费用项目列表"
row-key="id"
empty-text="暂无符合条件的费用项目"
>
<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>
<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 class="row-actions density-actions admin-text-actions">
<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"
v-if="access.can('firstleg:fee-item:edit')"
size="small"
:data-tone="(row as PlatformFeeItemResource).status === 'ACTIVE' ? 'danger' : 'success'"
:loading="statusLoadingIds.has(String((row as PlatformFeeItemResource).id))"
:disabled="loading"
@click="requestStatusChange(row as PlatformFeeItemResource)"
>{{ (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>
</div>
<span v-else class="muted-value">仅查看</span>
</div></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 #supplierCategory="{ row }">{{ (row as PlatformFeeItemResource).supplierCategory ? categoryLabels[(row as PlatformFeeItemResource).supplierCategory as SupplierCategory] : '未配置' }}</template>
<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>
<template #pagination-left>平台公共主数据 · 共 {{ total }} 项</template>
</DataTable></section>
</ListPageLayout>
<AppDrawer class="admin-density admin-density-drawer"
v-model="editorOpen"
:title="editorTitle"
size="min(620px, 94vw)"
:loading="editorLoading"
:confirm-loading="saving"
>
<el-form class="density-form" ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving || editorLoading">
<FormSection class="density-form-section" 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 class="density-form-section" title="费用方向" description="选择费用在提货和仓库环节的应收、应付属性。">
<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>
<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>
</FormSection>
<FormSection class="density-form-section" 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>
</el-form>
<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>
</AppDrawer>
<AppDialog class="admin-density-dialog"
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 class="admin-density-dialog"
v-model="deleteOpen"
title="删除费用项目"
confirm-text="确认删除"
confirm-type="danger"
:confirm-loading="deleting"
@confirm="confirmDelete"
>
<p class="delete-message">确定删除“<strong>{{ deleteTarget?.feeTypeName }}</strong>”吗?删除后无法恢复;已被业务单据使用的项目将由服务端拒绝删除。</p>
</AppDialog>
</AdminPage>
</template>
<style scoped>
.fee-items-page { min-width: 0; }
.fee-item-name { display: flex; min-width: 0; flex-direction: column; gap: var(--admin-space-1); }
.fee-item-name strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fee-item-name small { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.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, 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); }
.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>