Files
admin-web/src/modules/fee-items/pages/FeeItemsView.vue
T
2026-08-10 15:36:53 +08:00

473 lines
20 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 { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { Pencil, Plus, Power, Trash2 } 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 { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.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 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>
<div class="business-page fee-items-page">
<el-result v-if="!canView" icon="warning" title="无权查看费用项目" sub-title="请联系管理员开通费用项目查看权限" />
<template v-else>
<QueryToolbar
v-model="filters"
class="surface-panel"
ariaLabel="费用项目筛选"
:fields="queryFields"
preference-key="fee-items-query"
@search="applyFilters"
/>
<LoadErrorState
v-if="loadError"
title="费用项目加载失败"
:message="loadError.message"
:loading="loading"
@retry="loadFeeItems"
>
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
<section class="surface-panel table-panel">
<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-start>
<div><h2>费用项目列表</h2><p>平台公共主数据 · 共 {{ total }} 项</p></div>
</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 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>
</DataTable>
</section>
</template>
<AppDrawer
v-model="editorOpen"
:title="editorTitle"
size="min(620px, 94vw)"
:loading="editorLoading"
:confirm-loading="saving"
confirm-text="保存费用项目"
@confirm="saveFeeItem"
>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving || editorLoading">
<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>
<div class="form-section">
<h3>费用方向</h3>
<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>
</div>
<el-form-item label="说明"><el-input v-model="form.description" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入适用场景或核算说明" /></el-form-item>
</el-form>
</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>
</div>
</template>
<style scoped>
.fee-items-page { display: grid; gap: 16px; }
.fee-item-name { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.fee-item-name strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fee-item-name small { color: var(--cc-muted); font-size: 11px; }
.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: 12px; margin-bottom: 12px; }
.direction-grid fieldset { min-width: 0; margin: 0; padding: 12px; border: 1px solid var(--el-border-color); border-radius: 6px; }
.direction-grid legend { padding: 0 5px; color: var(--el-text-color-secondary); font-size: 13px; }
.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>