优化
This commit is contained in:
@@ -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>
|
||||
@@ -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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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[]
|
||||
Reference in New Issue
Block a user