Files
admin-web/src/modules/fee-items/pages/FeeItemsView.vue
T
2026-10-21 13:22:42 +08:00

511 lines
24 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/adminWorkbench.css'
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 InlineHelp from '@/shared/components/InlineHelp.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: '',
defaultPrice: null,
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' }],
defaultPrice: [{ type: 'number', min: 0, max: 9999999999.99, message: '默认价格须为非负金额,最多两位小数', trigger: 'change' }],
}
const hasPayableDirection = computed(() => form.pickupPayable || form.warehousePayable)
const canView = computed(() => access.can('firstleg:fee-item:view'))
const editorTitle = computed(() => form.id && form.feeTypeName.trim() ? `编辑费用项目 · ${form.feeTypeName.trim()}` : 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: 'defaultPrice', label: '默认价格(CNY)', width: 150, formatter: (row: PlatformFeeItemResource) => row.defaultPrice == null ? '—' : Number(row.defaultPrice).toFixed(2) },
{ 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,
defaultPrice: item.defaultPrice,
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(),
defaultPrice: form.defaultPrice ?? null,
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 admin-workbench" spacing="normal">
<el-result v-if="!canView" icon="warning" title="无权查看费用项目" sub-title="请联系管理员开通费用项目查看权限" />
<ListPageLayout v-else aria-label="费用项目管理">
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" 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 admin-density-drawer--editor admin-workbench is-drawer"
v-model="editorOpen"
:title="editorTitle"
size="min(620px, 94vw)"
:loading="editorLoading"
:confirm-loading="saving"
>
<el-form class="density-form density-dialog-sections" ref="formRef" :model="form" :rules="rules" label-position="left" label-width="100px" :disabled="saving || editorLoading">
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="费用基本信息">
<h3 class="density-dialog-section__title">基本信息 <InlineHelp title="默认价格说明">选择费用项目时自动带入,使用时仍可修改;清空表示不设置默认价格。</InlineHelp></h3>
<table class="density-table density-table--editable fee-item-editor-table"><tbody>
<tr><td><el-form-item label="费用名称" prop="feeTypeName" required><el-input v-model="form.feeTypeName" maxlength="128" placeholder="请输入费用名称" /></el-form-item></td><td><el-form-item label="费用分类" prop="category" required><el-input v-model="form.category" maxlength="64" placeholder="例如 仓储成本" /></el-form-item></td></tr>
<tr><td><el-form-item label="贸易范围" prop="tradeScope" required><el-segmented v-model="form.tradeScope" :options="[{ label: '国内', value: 'DOMESTIC' }, { label: '国际', value: 'INTERNATIONAL' }]" /></el-form-item></td><td><el-form-item label="默认价格(CNY)" prop="defaultPrice"><el-input-number v-model="form.defaultPrice" :min="0" :max="9999999999.99" :precision="2" controls-position="right" placeholder="不填则由使用时手工录入" /></el-form-item></td></tr>
</tbody></table>
</section>
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="费用方向与供应商">
<h3 class="density-dialog-section__title">费用方向 <InlineHelp title="费用方向说明">选择费用在提货和仓库环节的应收、应付属性。</InlineHelp></h3>
<table class="density-table density-table--editable fee-direction-table"><thead><tr><th scope="col">环节</th><th scope="col">应付</th><th scope="col">应收</th></tr></thead><tbody>
<tr><th scope="row">提货环节</th><td><el-checkbox v-model="form.pickupPayable" aria-label="提货环节应付" /></td><td><el-checkbox v-model="form.pickupReceivable" aria-label="提货环节应收" /></td></tr>
<tr><th scope="row">仓库环节</th><td><el-checkbox v-model="form.warehousePayable" aria-label="仓库环节应付" /></td><td><el-checkbox v-model="form.warehouseReceivable" aria-label="仓库环节应收" /></td></tr>
<tr><td colspan="3"><el-checkbox v-model="form.allocationFee">作为分摊费用</el-checkbox></td></tr>
<tr v-if="hasPayableDirection"><td colspan="3"><el-form-item label="供应商类别" required><el-select v-model="form.supplierCategory" placeholder="请选择供应商类别"><el-option v-for="(label, value) in categoryLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item></td></tr>
</tbody></table>
</section>
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="费用适用说明">
<h3 class="density-dialog-section__title">适用说明</h3>
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="说明"><el-input v-model="form.description" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入适用场景或核算说明" /></el-form-item></td></tr></tbody></table>
</section>
</el-form>
<template #footer="{close}">
<div class="density-actions density-actions--footer">
<el-button :disabled="saving" @click="close">取消</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 admin-workbench is-dialog"
v-model="statusOpen"
:title="`${statusTarget?.status === 'ACTIVE' ? '停用' : '启用'}费用项目${statusTarget?.feeTypeName?.trim() ? ' · ' + statusTarget.feeTypeName.trim() : ''}`"
:confirm-text="statusTarget?.status === 'ACTIVE' ? '确认停用' : '确认启用'"
:confirm-type="statusTarget?.status === 'ACTIVE' ? 'warning' : 'success'"
:confirm-loading="statusSaving"
@confirm="confirmStatusChange"
>
<p class="density-dialog-note">
确定{{ statusTarget?.status === 'ACTIVE' ? '停用' : '启用' }}“<strong>{{ statusTarget?.feeTypeName }}</strong>”吗?
<template v-if="statusTarget?.status === 'ACTIVE'">停用后该项目不能再用于新的费用记录。</template>
</p>
</AppDialog>
<AppDialog class="admin-density-dialog admin-workbench is-dialog"
v-model="deleteOpen"
:title="deleteTarget?.feeTypeName?.trim() ? `删除费用项目 · ${deleteTarget.feeTypeName.trim()}` : '删除费用项目'"
confirm-text="确认删除"
confirm-type="danger"
:confirm-loading="deleting"
@confirm="confirmDelete"
>
<p class="density-dialog-note">确定删除“<strong>{{ deleteTarget?.feeTypeName }}</strong>”吗?删除后无法恢复;已被业务单据使用的项目将由服务端拒绝删除。</p>
</AppDialog>
</AdminPage>
</template>
<style scoped>
.fee-items-page { min-width: 0; }
.fee-item-editor-table { table-layout: fixed; }
.fee-item-editor-table td { width: 50%; }
.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(--workbench-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; }
@media (max-width: 720px) {
.fee-item-editor-table tr { display: grid; }
.fee-item-editor-table td { width: auto; }
.table-actions { flex-wrap: wrap; }
}
</style>