This commit is contained in:
2026-08-10 15:36:53 +08:00
parent 5d97c7517a
commit f6556c57b5
54 changed files with 4107 additions and 170 deletions
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import {
changePlatformFeeItemStatus,
createPlatformFeeItem,
deletePlatformFeeItem,
getPlatformFeeItem,
listPlatformFeeItems,
updatePlatformFeeItem,
type SavePlatformFeeItemRequest,
} from './platformFeeItems'
vi.mock('@/shared/api/http', () => ({
http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn() },
unwrapApiResponse: <T>(response: { data: T }) => response.data,
}))
const payload: SavePlatformFeeItemRequest = {
category: '仓储成本',
tradeScope: 'INTERNATIONAL',
feeTypeName: '仓库租赁费',
pickupPayable: false,
pickupReceivable: false,
warehousePayable: true,
warehouseReceivable: true,
allocationFee: true,
description: null,
}
describe('platform fee item API', () => {
beforeEach(() => vi.clearAllMocks())
it('sends normalized public list filters without customer context', async () => {
vi.mocked(http.get).mockResolvedValue({
code: '200', message: 'success', data: { records: [], page: '2', size: '20', total: '21', pages: '2' },
})
await expect(listPlatformFeeItems({
category: ' 仓储成本 ', tradeScope: 'INTERNATIONAL',
keyword: ' 租赁 ', status: 'ACTIVE', page: 2, size: 20,
})).resolves.toMatchObject({ page: 2, size: 20, total: 21, pages: 2 })
expect(http.get).toHaveBeenCalledWith('/platform/v1/fee-items', { params: {
category: '仓储成本', tradeScope: 'INTERNATIONAL',
keyword: '租赁', status: 'ACTIVE', page: 2, size: 20,
} })
})
it('uses public detail and mutation contracts', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: { id: '11' } })
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '11' } })
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: { id: '11' } })
vi.mocked(http.patch).mockResolvedValue({ code: '200', message: 'success', data: null })
vi.mocked(http.delete).mockResolvedValue({ code: '200', message: 'success', data: null })
await getPlatformFeeItem('11')
await createPlatformFeeItem(payload)
await updatePlatformFeeItem('11', payload)
await changePlatformFeeItemStatus('11', 'DISABLED')
await deletePlatformFeeItem('11')
expect(http.get).toHaveBeenCalledWith('/platform/v1/fee-items/11')
expect(http.post).toHaveBeenCalledWith('/platform/v1/fee-items', payload)
expect(http.put).toHaveBeenCalledWith('/platform/v1/fee-items/11', payload)
expect(http.patch).toHaveBeenCalledWith('/platform/v1/fee-items/11/status', { status: 'DISABLED' })
expect(http.delete).toHaveBeenCalledWith('/platform/v1/fee-items/11')
})
})
@@ -0,0 +1,119 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export type FeeItemTradeScope = 'DOMESTIC' | 'INTERNATIONAL'
export type FeeItemStatus = 'ACTIVE' | 'DISABLED'
export interface PlatformFeeItemResource {
id: number | string
category: string
tradeScope: FeeItemTradeScope
feeCode: string | null
feeTypeName: string
pickupPayable: boolean
pickupReceivable: boolean
warehousePayable: boolean
warehouseReceivable: boolean
allocationFee: boolean
description: string | null
status: FeeItemStatus
createdByName?: string | null
updatedByName?: string | null
createdAt?: string | null
updatedAt?: string | null
}
export interface SavePlatformFeeItemRequest {
category: string
tradeScope: FeeItemTradeScope
feeTypeName: string
pickupPayable: boolean
pickupReceivable: boolean
warehousePayable: boolean
warehouseReceivable: boolean
allocationFee: boolean
description: string | null
}
export interface PlatformFeeItemQuery {
category?: string
tradeScope?: FeeItemTradeScope | ''
keyword?: string
status?: FeeItemStatus | ''
page: number
size: number
}
export interface PlatformFeeItemPage {
records: PlatformFeeItemResource[]
page: number
size: number
total: number
pages: number
}
const basePath = '/platform/v1/fee-items'
export async function listPlatformFeeItems(query: PlatformFeeItemQuery) {
const response = await http.get<ApiEnvelope<PlatformFeeItemPage>, ApiEnvelope<PlatformFeeItemPage>>(basePath, {
params: {
category: query.category?.trim() || undefined,
tradeScope: query.tradeScope || undefined,
keyword: query.keyword?.trim() || undefined,
status: query.status || undefined,
page: query.page,
size: query.size,
},
})
const result = unwrapApiResponse(response)
return {
...result,
records: result.records || [],
page: Number(result.page),
size: Number(result.size),
total: Number(result.total),
pages: Number(result.pages),
}
}
export async function getPlatformFeeItem(id: number | string) {
const response = await http.get<ApiEnvelope<PlatformFeeItemResource>, ApiEnvelope<PlatformFeeItemResource>>(
`${basePath}/${id}`,
)
return unwrapApiResponse(response)
}
export async function createPlatformFeeItem(payload: SavePlatformFeeItemRequest) {
const response = await http.post<ApiEnvelope<PlatformFeeItemResource>, ApiEnvelope<PlatformFeeItemResource>>(
basePath,
payload,
)
return unwrapApiResponse(response)
}
export async function updatePlatformFeeItem(id: number | string, payload: SavePlatformFeeItemRequest) {
const response = await http.put<ApiEnvelope<PlatformFeeItemResource>, ApiEnvelope<PlatformFeeItemResource>>(
`${basePath}/${id}`,
payload,
)
return unwrapApiResponse(response)
}
export async function changePlatformFeeItemStatus(id: number | string, status: FeeItemStatus) {
const response = await http.patch<ApiEnvelope<null>, ApiEnvelope<null>>(
`${basePath}/${id}/status`,
{ status },
)
return unwrapApiResponse(response)
}
export async function deletePlatformFeeItem(id: number | string) {
const response = await http.delete<ApiEnvelope<null>, ApiEnvelope<null>>(
`${basePath}/${id}`,
)
return unwrapApiResponse(response)
}
@@ -0,0 +1,82 @@
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { listPlatformFeeItems } from '@/modules/fee-items/api/platformFeeItems'
import FeeItemsView from './FeeItemsView.vue'
vi.mock('@/modules/fee-items/api/platformFeeItems', () => ({
listPlatformFeeItems: vi.fn(),
getPlatformFeeItem: vi.fn(),
createPlatformFeeItem: vi.fn(),
updatePlatformFeeItem: vi.fn(),
changePlatformFeeItemStatus: vi.fn(),
deletePlatformFeeItem: vi.fn(),
}))
class ResizeObserverStub implements ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
function createMemoryStorage(): Storage {
const values = new Map<string, string>()
return {
get length() { return values.size },
clear: () => values.clear(),
getItem: (key) => values.get(key) ?? null,
key: (index) => [...values.keys()][index] ?? null,
removeItem: (key) => values.delete(key),
setItem: (key, value) => values.set(key, value),
}
}
async function mountView(permissions: string[]) {
const pinia = createPinia()
setActivePinia(pinia)
const auth = useAuthStore()
auth.token = 'token'
auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin()
const wrapper = mount(FeeItemsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
return wrapper
}
enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
describe('FeeItemsView public master data and access', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', createMemoryStorage())
vi.stubGlobal('sessionStorage', createMemoryStorage())
document.body.innerHTML = ''
vi.mocked(listPlatformFeeItems).mockReset().mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
})
it('loads public fee items immediately without customer selection', async () => {
const wrapper = await mountView(['firstleg:fee-item:view', 'firstleg:fee-item:create'])
expect(wrapper.find('.query-toolbar').exists()).toBe(true)
expect(wrapper.find('.data-table').exists()).toBe(true)
expect(wrapper.text()).toContain('平台公共主数据')
expect(listPlatformFeeItems).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 20 }))
const vm = wrapper.vm as unknown as { applyFilters(model: Record<string, string>): void }
vm.applyFilters({ keyword: '', category: '', tradeScope: '', status: '' })
await flushPromises()
expect(listPlatformFeeItems).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 20 }))
})
it('renders an explicit no-access state without loading customers or fee items', async () => {
const wrapper = await mountView([])
expect(wrapper.text()).toContain('无权查看费用项目')
expect(listPlatformFeeItems).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,472 @@
<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>
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { feeItemRoutes } from './routes'
describe('fee item routes', () => {
it('registers the formal platform master-data management page', () => {
expect(feeItemRoutes).toHaveLength(1)
expect(feeItemRoutes[0]).toMatchObject({
path: 'master-data/fee-items',
name: 'fee-items',
meta: { permission: 'firstleg:fee-item:view' },
})
})
})
+14
View File
@@ -0,0 +1,14 @@
import type { RouteRecordRaw } from 'vue-router'
export const feeItemRoutes = [
{
path: 'master-data/fee-items',
name: 'fee-items',
component: () => import('@/modules/fee-items/pages/FeeItemsView.vue'),
meta: {
title: '费用项目',
description: '按客户维护头程费用项目及其适用范围',
permission: 'firstleg:fee-item:view',
},
},
] satisfies RouteRecordRaw[]