优化
This commit is contained in:
@@ -43,7 +43,7 @@ function mountView(pinia = createPinia()) {
|
||||
AdminPage: { template: '<main><slot /></main>' },
|
||||
ListPageLayout: { template: '<section><slot name="toolbar" /><slot name="state" /><slot /></section>' },
|
||||
FormSection: { template: '<section><slot /></section>' },
|
||||
AppDrawer: { template: '<section><slot /><slot name="footer" /></section>' },
|
||||
AppDialog: { template: '<section><slot /><slot name="footer" /></section>' },
|
||||
LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}<slot name="details" /></div>' },
|
||||
DataTable: { props: ['data'], template: '<section><slot name="toolbar-actions" /><span data-testid="row-count">{{ data.length }}</span></section>' },
|
||||
'el-alert': { props: ['title'], template: '<div role="alert">{{ title }}<slot name="title" /></div>' },
|
||||
@@ -95,6 +95,15 @@ describe('RolesView', () => {
|
||||
expect(rolesSource).toContain('aria-label="平台角色列表"')
|
||||
})
|
||||
|
||||
it('uses the prototype-sized centered role editor with responsive split panes', () => {
|
||||
expect(rolesSource).toContain('<AppDialog v-model="editorOpen"')
|
||||
expect(rolesSource).toContain('width="min(860px, calc(100vw - 28px))"')
|
||||
expect(rolesSource).not.toContain('<AppDrawer v-model="editorOpen"')
|
||||
expect(rolesSource).toContain('grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.3fr)')
|
||||
expect(rolesSource).toContain('.role-editor-layout { grid-template-columns: minmax(0, 1fr); }')
|
||||
expect(rolesSource).toContain(':global(.role-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }')
|
||||
})
|
||||
|
||||
it('loads platform roles from IAM for a server session', async () => {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
|
||||
@@ -19,7 +19,7 @@ import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import FormSection from '@/shared/components/FormSection.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
@@ -259,11 +259,30 @@ onMounted(loadRoles)
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDrawer v-model="editorOpen" :title="form.id ? '配置角色' : '新增角色'" size="760px" class="business-drawer editor-drawer" :modal="false" :confirm-loading="saving" confirm-text="保存角色" :show-confirm="form.id ? canEdit || canGrant : canCreate" @confirm="saveRole">
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-position="top" class="drawer-form">
|
||||
<FormSection title="基本信息" description="维护角色名称、编码和职责说明。"><div class="form-grid two-cols"><el-form-item label="角色名称" prop="name" required><el-input v-model="form.name" :disabled="Boolean(form.id) && !canEdit" placeholder="如:订单运营专员" maxlength="64" /></el-form-item><el-form-item label="角色编码" prop="code" required><el-input v-model="form.code" :disabled="Boolean(form.id)" placeholder="如:ORDER_OPERATOR" maxlength="64" /></el-form-item></div><el-form-item label="角色说明"><el-input v-model="form.description" :disabled="Boolean(form.id) && !canEdit" type="textarea" :rows="2" placeholder="说明该角色的工作范围" maxlength="200" show-word-limit /></el-form-item></FormSection>
|
||||
<FormSection v-if="canGrant" title="菜单与操作权限" :description="`保存时按后端契约全量替换当前角色权限;当前 ${form.permissionIds.length} 项授权。`"><el-tree ref="treeRef" class="permission-tree" :data="permissionTree" node-key="id" show-checkbox check-strictly default-expand-all :props="{ label: 'name', children: 'children' }" @check="syncPermissionIds"><template #default="{ data }"><div class="permission-node"><span>{{ data.name }}</span><code>{{ data.code }}</code></div></template></el-tree></FormSection>
|
||||
<AppDialog v-model="editorOpen" :title="form.id ? '配置角色' : '新增角色'" width="min(860px, calc(100vw - 28px))" class="role-editor-dialog" :confirm-loading="saving" confirm-text="保存角色" :show-confirm="form.id ? canEdit || canGrant : canCreate" destroy-on-close @confirm="saveRole">
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-position="top" class="role-editor-form">
|
||||
<div class="role-editor-layout">
|
||||
<FormSection title="基本信息" description="维护角色名称、编码和职责说明。" class="role-editor-basic">
|
||||
<el-form-item label="角色名称" prop="name" required><el-input v-model="form.name" :disabled="Boolean(form.id) && !canEdit" placeholder="如:订单运营专员" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="角色编码" prop="code" required><el-input v-model="form.code" :disabled="Boolean(form.id)" placeholder="如:ORDER_OPERATOR" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="角色说明"><el-input v-model="form.description" :disabled="Boolean(form.id) && !canEdit" type="textarea" :rows="3" placeholder="说明该角色的工作范围" maxlength="200" show-word-limit /></el-form-item>
|
||||
</FormSection>
|
||||
<FormSection v-if="canGrant" title="菜单与操作权限" :description="`保存时按后端契约全量替换当前角色权限;当前 ${form.permissionIds.length} 项授权。`" class="role-editor-permissions"><el-tree ref="treeRef" class="permission-tree" :data="permissionTree" node-key="id" show-checkbox check-strictly default-expand-all :props="{ label: 'name', children: 'children' }" @check="syncPermissionIds"><template #default="{ data }"><div class="permission-node"><span>{{ data.name }}</span><code>{{ data.code }}</code></div></template></el-tree></FormSection>
|
||||
</div>
|
||||
</el-form>
|
||||
</AppDrawer>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.role-editor-layout { display: grid; grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.3fr); gap: 20px; }
|
||||
.role-editor-permissions { min-width: 0; padding-left: 20px; border-left: 1px solid var(--cc-border); }
|
||||
.permission-tree { max-height: 380px; overflow-y: auto; }
|
||||
:global(.role-editor-dialog) { max-height: min(760px, calc(100vh - 32px)); display: flex; flex-direction: column; }
|
||||
:global(.role-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }
|
||||
:global(.role-editor-dialog .el-dialog__footer) { flex: 0 0 auto; }
|
||||
@media (max-width: 720px) {
|
||||
.role-editor-layout { grid-template-columns: minmax(0, 1fr); }
|
||||
.role-editor-permissions { padding-top: 18px; padding-left: 0; border-top: 1px solid var(--cc-border); border-left: 0; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { adjustContainerFee, approveContainerFee, createContainerFee, listContainerCostDetails, listContainerFees, rejectContainerFee, voidContainerFee } from './containerFees'
|
||||
import { adjustContainerFee, approveContainerFee, createContainerFee, listContainerCostDetails, listContainerFeePartnerOptions, listContainerFees, rejectContainerFee, voidContainerFee } from './containerFees'
|
||||
|
||||
describe('container fee API', () => {
|
||||
beforeEach(() => vi.restoreAllMocks())
|
||||
it('passes accountId and exact controller DTOs', async () => {
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: [] })
|
||||
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { id: 3, sourceVersion: 1 } })
|
||||
const values = { feeItemId: '7', direction: 'PAYABLE' as const, businessDate: '2026-08-12', amount: 100, currency: 'CNY' as const, settlementIncluded: true, remark: null }
|
||||
const values = { feeItemId: '7', direction: 'PAYABLE' as const, partnerId: '41', partnerName: '承运商A', businessDate: '2026-08-12', amount: 100, currency: 'CNY' as const, settlementIncluded: true, remark: null }
|
||||
await listContainerFees('9', '11'); await createContainerFee('9', '11', values)
|
||||
await approveContainerFee('9', '11', '3'); await rejectContainerFee('9', '11', '3', '资料不全')
|
||||
await adjustContainerFee('9', '11', '3', 2, values); await voidContainerFee('9', '11', '3', 3)
|
||||
@@ -17,6 +17,12 @@ describe('container fee API', () => {
|
||||
expect(post).toHaveBeenCalledWith('/platform/v1/loading-plans/9/fee-details/3/void', { expectedSourceVersion: 3 }, { params: { accountId: '11' } })
|
||||
})
|
||||
|
||||
it('loads only ACTIVE counterparties for payable selection', async () => {
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { records: [{ id: 41, partnerName: '承运商A' }] } })
|
||||
await expect(listContainerFeePartnerOptions()).resolves.toEqual([{ id: '41', partnerName: '承运商A' }])
|
||||
expect(get).toHaveBeenCalledWith('/platform/v1/partners', { params: { status: 'ACTIVE', page: 1, size: 200 } })
|
||||
})
|
||||
|
||||
it('reads allocation rows from Finance and never calculates them', async () => {
|
||||
const get = vi.spyOn(http, 'get')
|
||||
.mockResolvedValueOnce({ code: '200', data: [{ id: 81, containerId: 99 }] })
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface ContainerFeeResource {
|
||||
feeItemId: string
|
||||
feeItemName: string
|
||||
direction: ContainerFeeDirection
|
||||
partnerId: string | null
|
||||
partnerName: string | null
|
||||
businessDate: string
|
||||
amount: number
|
||||
currency: 'CNY'
|
||||
@@ -27,6 +29,8 @@ export interface ContainerFeeResource {
|
||||
export interface ContainerFeeSaveRequest {
|
||||
feeItemId: string
|
||||
direction: ContainerFeeDirection
|
||||
partnerId: string | null
|
||||
partnerName: string | null
|
||||
businessDate: string
|
||||
amount: number
|
||||
currency: 'CNY'
|
||||
@@ -43,6 +47,11 @@ export interface ApplicableFeeItem {
|
||||
pickupPayable: boolean
|
||||
}
|
||||
|
||||
export interface ContainerFeePartnerOption {
|
||||
id: string
|
||||
partnerName: string
|
||||
}
|
||||
|
||||
export interface ContainerCostAllocation {
|
||||
id: string
|
||||
orderId: string
|
||||
@@ -73,6 +82,7 @@ function normalizeFee(raw: Record<string, unknown>): ContainerFeeResource {
|
||||
id: id(raw.id), loadingPlanId: id(raw.loadingPlanId), containerBookingId: raw.containerBookingId == null ? null : id(raw.containerBookingId),
|
||||
containerNo: raw.containerNo == null ? null : String(raw.containerNo), feeItemId: id(raw.feeItemId),
|
||||
feeItemName: String(raw.feeItemNameSnapshot || ''), direction: raw.direction as ContainerFeeDirection,
|
||||
partnerId: raw.partnerId == null ? null : id(raw.partnerId), partnerName: raw.partnerName == null ? null : String(raw.partnerName),
|
||||
businessDate: String(raw.businessDate || ''), amount: numberValue(raw.amount), currency: 'CNY',
|
||||
settlementIncluded: Boolean(raw.settlementIncluded), reviewStatus: raw.reviewStatus as ContainerFeeReviewStatus,
|
||||
sourceStatus: raw.sourceStatus as ContainerFeeResource['sourceStatus'], sourceVersion: numberValue(raw.sourceVersion),
|
||||
@@ -113,6 +123,11 @@ export async function listApplicableFeeItems() {
|
||||
return (unwrapApiResponse(response).records || []).map((raw) => ({ id: id(raw.id), name: String(raw.feeTypeName || ''), warehouseReceivable: Boolean(raw.warehouseReceivable), warehousePayable: Boolean(raw.warehousePayable), pickupReceivable: Boolean(raw.pickupReceivable), pickupPayable: Boolean(raw.pickupPayable) }))
|
||||
}
|
||||
|
||||
export async function listContainerFeePartnerOptions(): Promise<ContainerFeePartnerOption[]> {
|
||||
const response = await http.get<ApiEnvelope<{ records: Record<string, unknown>[] }>, ApiEnvelope<{ records: Record<string, unknown>[] }>>('/platform/v1/partners', { params: { status: 'ACTIVE', page: 1, size: 200 } })
|
||||
return (unwrapApiResponse(response).records || []).map((raw) => ({ id: id(raw.id), partnerName: String(raw.partnerName || '') }))
|
||||
}
|
||||
|
||||
export async function listContainerCostDetails(customerId: string, containerId: string): Promise<ContainerCostDetail[]> {
|
||||
const response = await http.get<ApiEnvelope<Record<string, unknown>[]>, ApiEnvelope<Record<string, unknown>[]>>('/platform/finance/v1/cost-entries', { params: { customerId } })
|
||||
const costs = unwrapApiResponse(response).filter((raw) => id(raw.containerId) === containerId)
|
||||
|
||||
@@ -6,12 +6,12 @@ import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFees } from '../api/containerFees'
|
||||
import { createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeePartnerOptions, listContainerFees } from '../api/containerFees'
|
||||
import ContainerFeesPanel from './ContainerFeesPanel.vue'
|
||||
|
||||
vi.mock('../api/containerFees', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/containerFees')>()
|
||||
return { ...actual, listContainerFees: vi.fn(), listApplicableFeeItems: vi.fn(), listContainerCostDetails: vi.fn(), createContainerFee: vi.fn(), adjustContainerFee: vi.fn(), approveContainerFee: vi.fn(), rejectContainerFee: vi.fn(), voidContainerFee: vi.fn() }
|
||||
return { ...actual, listContainerFees: vi.fn(), listApplicableFeeItems: vi.fn(), listContainerFeePartnerOptions: vi.fn(), listContainerCostDetails: vi.fn(), createContainerFee: vi.fn(), adjustContainerFee: vi.fn(), approveContainerFee: vi.fn(), rejectContainerFee: vi.fn(), voidContainerFee: vi.fn() }
|
||||
})
|
||||
|
||||
const panel = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/ContainerFeesPanel.vue'), 'utf8')
|
||||
@@ -30,6 +30,7 @@ async function mountPanel(permissions: string[], customers = [{ accountId: '11',
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); vi.stubGlobal('sessionStorage', memoryStorage())
|
||||
vi.mocked(listContainerFees).mockReset().mockResolvedValue([]); vi.mocked(listApplicableFeeItems).mockReset().mockResolvedValue([])
|
||||
vi.mocked(listContainerFeePartnerOptions).mockReset().mockResolvedValue([])
|
||||
vi.mocked(listContainerCostDetails).mockReset().mockResolvedValue([]); vi.mocked(createContainerFee).mockReset()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
@@ -51,6 +52,7 @@ describe('ContainerFeesPanel contract', () => {
|
||||
expect(panel).toContain('adjustContainerFee')
|
||||
expect(panel).toContain('voidContainerFee')
|
||||
expect(panel).toContain('调整后版本递增并重新进入待审核状态')
|
||||
expect(panel).toContain("row.partnerName || '服务端未返回'")
|
||||
})
|
||||
|
||||
it('shows only server allocations and supplier snapshots', () => {
|
||||
@@ -71,7 +73,7 @@ describe('ContainerFeesPanel contract', () => {
|
||||
state.accountId = '11'; await flushPromises(); state.editorOpen = true
|
||||
state.accountId = '22'; await flushPromises()
|
||||
expect(state.fees).toEqual([]); expect(state.costs).toEqual([]); expect(state.editorOpen).toBe(false)
|
||||
resolveFirst([{ id: 'old', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '旧客户费用', direction: 'RECEIVABLE', businessDate: '2026-08-12', amount: 10, currency: 'CNY', settlementIncluded: true, reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null }])
|
||||
resolveFirst([{ id: 'old', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '旧客户费用', direction: 'RECEIVABLE', partnerId: null, partnerName: null, businessDate: '2026-08-12', amount: 10, currency: 'CNY', settlementIncluded: true, reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null }])
|
||||
await flushPromises()
|
||||
expect(state.fees).toEqual([])
|
||||
expect(listContainerCostDetails).not.toHaveBeenCalledWith('11', expect.anything())
|
||||
@@ -87,11 +89,55 @@ describe('ContainerFeesPanel contract', () => {
|
||||
|
||||
it('hides all mutation buttons when permissions are missing', async () => {
|
||||
vi.mocked(listContainerFees).mockResolvedValue([
|
||||
{ id: '1', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '待审费用', direction: 'RECEIVABLE', businessDate: '2026-08-12', amount: 10, currency: 'CNY', settlementIncluded: true, reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
|
||||
{ id: '2', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '2', feeItemName: '已审费用', direction: 'PAYABLE', businessDate: '2026-08-12', amount: 20, currency: 'CNY', settlementIncluded: true, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
|
||||
{ id: '1', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '待审费用', direction: 'RECEIVABLE', partnerId: null, partnerName: null, businessDate: '2026-08-12', amount: 10, currency: 'CNY', settlementIncluded: true, reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
|
||||
{ id: '2', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '2', feeItemName: '已审费用', direction: 'PAYABLE', partnerId: '41', partnerName: '承运商A', businessDate: '2026-08-12', amount: 20, currency: 'CNY', settlementIncluded: true, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
|
||||
])
|
||||
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view'])
|
||||
expect(wrapper.text()).not.toContain('新增费用')
|
||||
for (const label of ['通过', '驳回', '调整', '作废']) expect(wrapper.findAll('button').some((button) => button.text() === label)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps receivable usable when counterparty permission is missing', async () => {
|
||||
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
|
||||
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'])
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; form: { direction: string; partnerId: string | null; partnerName: string | null } } }).setupState
|
||||
state.openCreate(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('当前账号缺少往来单位查看权限,仅可维护应收费用')
|
||||
expect(listApplicableFeeItems).toHaveBeenCalledTimes(1)
|
||||
expect(listContainerFeePartnerOptions).not.toHaveBeenCalled()
|
||||
expect(state.form.direction).toBe('RECEIVABLE')
|
||||
})
|
||||
|
||||
it('isolates a counterparty load failure and clears partner fields for receivable', async () => {
|
||||
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
|
||||
vi.mocked(listContainerFeePartnerOptions).mockRejectedValue(new Error('partners unavailable'))
|
||||
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:counterparty:list'])
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; form: { direction: string; partnerId: string | null; partnerName: string | null }; feeItems: unknown[] } }).setupState
|
||||
state.openCreate(); await flushPromises()
|
||||
expect(state.feeItems).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('往来单位加载失败')
|
||||
state.form.direction = 'PAYABLE'; await flushPromises()
|
||||
state.form.partnerId = '41'; state.form.partnerName = '旧快照'; state.form.direction = 'RECEIVABLE'; await flushPromises()
|
||||
expect(state.form.partnerId).toBeNull(); expect(state.form.partnerName).toBeNull()
|
||||
})
|
||||
|
||||
it('submits the selected ACTIVE counterparty snapshot for payable fees', async () => {
|
||||
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
|
||||
vi.mocked(listContainerFeePartnerOptions).mockResolvedValue([{ id: '41', partnerName: '承运商A' }])
|
||||
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:counterparty:list'])
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; save(): Promise<void>; form: { direction: string; feeItemId: string; partnerId: string | null; amount: number } } }).setupState
|
||||
state.openCreate(); await flushPromises()
|
||||
state.form.direction = 'PAYABLE'; state.form.feeItemId = '7'; state.form.partnerId = '41'; state.form.amount = 100; await flushPromises()
|
||||
await state.save()
|
||||
expect(createContainerFee).toHaveBeenCalledWith('9', '11', expect.objectContaining({ direction: 'PAYABLE', partnerId: '41', partnerName: '承运商A' }))
|
||||
})
|
||||
|
||||
it('requires fee-item permission before opening a write form', async () => {
|
||||
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit'])
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; editorOpen: boolean } }).setupState
|
||||
state.openCreate(); await flushPromises()
|
||||
expect(state.editorOpen).toBe(false)
|
||||
expect(wrapper.text()).toContain('当前账号缺少费用项目查看权限')
|
||||
expect(listApplicableFeeItems).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,28 +9,35 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { adjustContainerFee, approveContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFees, rejectContainerFee, voidContainerFee, type ApplicableFeeItem, type ContainerCostDetail, type ContainerFeeDirection, type ContainerFeeResource, type ContainerFeeSaveRequest } from '../api/containerFees'
|
||||
import { adjustContainerFee, approveContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeePartnerOptions, listContainerFees, rejectContainerFee, voidContainerFee, type ApplicableFeeItem, type ContainerCostDetail, type ContainerFeePartnerOption, type ContainerFeeResource, type ContainerFeeSaveRequest } from '../api/containerFees'
|
||||
|
||||
const props = defineProps<{ planId: string; customers: Array<{ accountId: string; customerName: string | null }> }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const accountId = ref('')
|
||||
const fees = ref<ContainerFeeResource[]>([])
|
||||
const feeItems = ref<ApplicableFeeItem[]>([])
|
||||
const partners = ref<ContainerFeePartnerOption[]>([])
|
||||
const costs = ref<ContainerCostDetail[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const feeItemsError = ref<ApiErrorDetails | null>(null)
|
||||
const partnersError = ref<ApiErrorDetails | null>(null)
|
||||
const feeItemsLoading = ref(false)
|
||||
const partnersLoading = ref(false)
|
||||
const editorOpen = ref(false)
|
||||
const rejectOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const target = ref<ContainerFeeResource | null>(null)
|
||||
const formRef = ref<FormInstance>()
|
||||
const form = reactive<ContainerFeeSaveRequest>({ feeItemId: '', direction: 'RECEIVABLE', businessDate: new Date().toISOString().slice(0, 10), amount: 0, currency: 'CNY', settlementIncluded: true, remark: null })
|
||||
const form = reactive<ContainerFeeSaveRequest>({ feeItemId: '', direction: 'RECEIVABLE', partnerId: null, partnerName: null, businessDate: new Date().toISOString().slice(0, 10), amount: 0, currency: 'CNY', settlementIncluded: true, remark: null })
|
||||
const rejection = ref('')
|
||||
let loadSequence = 0
|
||||
|
||||
const canView = computed(() => access.can('firstleg:inbound:fee-detail:view'))
|
||||
const canEdit = computed(() => access.can('firstleg:inbound:fee-detail:edit'))
|
||||
const canReview = computed(() => access.can('firstleg:inbound:fee-detail:review'))
|
||||
const canViewFeeItems = computed(() => access.can('firstleg:fee-item:view'))
|
||||
const canViewPartners = computed(() => access.can('platform:counterparty:list'))
|
||||
const customerOptions = computed(() => {
|
||||
const unique = new Map<string, string>()
|
||||
for (const item of props.customers) if (item.accountId) unique.set(item.accountId, item.customerName || '客户信息缺失')
|
||||
@@ -44,6 +51,7 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', fixed: 'left', width: 300, slotName: 'actions', hideable: false, movable: false },
|
||||
{ key: 'feeItemName', prop: 'feeItemName', label: '费用项目', minWidth: 150 },
|
||||
{ key: 'direction', label: '方向', width: 90, slotName: 'direction' },
|
||||
{ key: 'partner', label: '往来单位/客户', minWidth: 170, slotName: 'partner' },
|
||||
{ key: 'amount', label: '金额(CNY)', width: 120, align: 'right', slotName: 'amount' },
|
||||
{ key: 'businessDate', prop: 'businessDate', label: '业务日期', width: 120 },
|
||||
{ key: 'reviewStatus', label: '审核状态', width: 110, slotName: 'reviewStatus' },
|
||||
@@ -62,8 +70,26 @@ const rules: FormRules = { feeItemId: [{ required: true, message: '请选择费
|
||||
|
||||
function resetForm(item?: ContainerFeeResource) {
|
||||
target.value = item || null
|
||||
Object.assign(form, item ? { feeItemId: item.feeItemId, direction: item.direction, businessDate: item.businessDate, amount: item.amount, currency: 'CNY', settlementIncluded: item.settlementIncluded, remark: item.remark }
|
||||
: { feeItemId: '', direction: 'RECEIVABLE', businessDate: new Date().toISOString().slice(0, 10), amount: 0, currency: 'CNY', settlementIncluded: true, remark: null })
|
||||
Object.assign(form, item ? { feeItemId: item.feeItemId, direction: item.direction, partnerId: item.partnerId, partnerName: item.partnerName, businessDate: item.businessDate, amount: item.amount, currency: 'CNY', settlementIncluded: item.settlementIncluded, remark: item.remark }
|
||||
: { feeItemId: '', direction: 'RECEIVABLE', partnerId: null, partnerName: null, businessDate: new Date().toISOString().slice(0, 10), amount: 0, currency: 'CNY', settlementIncluded: true, remark: null })
|
||||
}
|
||||
|
||||
async function loadFeeItems() {
|
||||
feeItems.value = []; feeItemsError.value = null
|
||||
if (!canViewFeeItems.value) return
|
||||
feeItemsLoading.value = true
|
||||
try { feeItems.value = await listApplicableFeeItems() }
|
||||
catch (cause) { feeItemsError.value = getApiErrorDetails(cause, '费用项目加载失败') }
|
||||
finally { feeItemsLoading.value = false }
|
||||
}
|
||||
|
||||
async function loadPartners() {
|
||||
partners.value = []; partnersError.value = null
|
||||
if (!canViewPartners.value) return
|
||||
partnersLoading.value = true
|
||||
try { partners.value = await listContainerFeePartnerOptions() }
|
||||
catch (cause) { partnersError.value = getApiErrorDetails(cause, '往来单位加载失败') }
|
||||
finally { partnersLoading.value = false }
|
||||
}
|
||||
|
||||
async function load() {
|
||||
@@ -72,9 +98,9 @@ async function load() {
|
||||
const requestedAccountId = accountId.value
|
||||
loading.value = true; error.value = null
|
||||
try {
|
||||
const [feeRows, items] = await Promise.all([listContainerFees(props.planId, requestedAccountId), listApplicableFeeItems()])
|
||||
const feeRows = await listContainerFees(props.planId, requestedAccountId)
|
||||
if (sequence !== loadSequence || requestedAccountId !== accountId.value) return
|
||||
fees.value = feeRows; feeItems.value = items
|
||||
fees.value = feeRows
|
||||
costs.value = []
|
||||
const currentContainerId = feeRows.find((item) => item.containerBookingId)?.containerBookingId
|
||||
if (currentContainerId) {
|
||||
@@ -86,14 +112,19 @@ async function load() {
|
||||
finally { if (sequence === loadSequence) loading.value = false }
|
||||
}
|
||||
|
||||
function openCreate() { if (!canEdit.value || !accountId.value) return; resetForm(); editorOpen.value = true }
|
||||
function openAdjust(item: ContainerFeeResource) { if (!canEdit.value || !accountId.value) return; resetForm(item); editorOpen.value = true }
|
||||
function openCreate() { if (!canEdit.value || !canViewFeeItems.value || !accountId.value) return; resetForm(); editorOpen.value = true; void Promise.all([loadFeeItems(), loadPartners()]) }
|
||||
function openAdjust(item: ContainerFeeResource) { if (!canEdit.value || !canViewFeeItems.value || !accountId.value) return; resetForm(item); editorOpen.value = true; void Promise.all([loadFeeItems(), loadPartners()]) }
|
||||
async function save() {
|
||||
if (!canEdit.value || !accountId.value || !await formRef.value?.validate().catch(() => false)) return
|
||||
if (!canEdit.value || !canViewFeeItems.value || !accountId.value) return
|
||||
if (form.direction === 'PAYABLE' && (!canViewPartners.value || !form.partnerId)) { ElMessage.warning(canViewPartners.value ? '应付费用必须选择往来单位' : '当前账号缺少往来单位查看权限,无法保存应付费用'); return }
|
||||
if (!await formRef.value?.validate().catch(() => false)) return
|
||||
const partner = partners.value.find((item) => item.id === String(form.partnerId))
|
||||
const payload: ContainerFeeSaveRequest = { ...form, partnerId: form.direction === 'PAYABLE' ? form.partnerId : null, partnerName: form.direction === 'PAYABLE' ? partner?.partnerName || null : null }
|
||||
if (form.direction === 'PAYABLE' && !payload.partnerName) { ElMessage.warning('请选择有效的往来单位'); return }
|
||||
saving.value = true
|
||||
try {
|
||||
if (target.value) await adjustContainerFee(props.planId, accountId.value, target.value.id, target.value.sourceVersion, { ...form })
|
||||
else await createContainerFee(props.planId, accountId.value, { ...form })
|
||||
if (target.value) await adjustContainerFee(props.planId, accountId.value, target.value.id, target.value.sourceVersion, payload)
|
||||
else await createContainerFee(props.planId, accountId.value, payload)
|
||||
editorOpen.value = false; ElMessage.success(target.value ? '费用调整已提交审核' : '费用已新增'); await load()
|
||||
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '费用保存失败').message) }
|
||||
finally { saving.value = false }
|
||||
@@ -116,35 +147,40 @@ function money(value: number) { return value.toLocaleString('zh-CN', { minimumFr
|
||||
function initializeCustomer() { accountId.value = customerOptions.value.length === 1 ? customerOptions.value[0].value : '' }
|
||||
function clearCustomerContext() {
|
||||
loadSequence += 1
|
||||
fees.value = []; costs.value = []; feeItems.value = []; error.value = null; loading.value = false
|
||||
fees.value = []; costs.value = []; feeItems.value = []; partners.value = []; error.value = null; feeItemsError.value = null; partnersError.value = null; loading.value = false; feeItemsLoading.value = false; partnersLoading.value = false
|
||||
editorOpen.value = false; rejectOpen.value = false; target.value = null; rejection.value = ''
|
||||
resetForm()
|
||||
}
|
||||
watch(customerOptions, initializeCustomer, { immediate: true })
|
||||
watch(accountId, () => { clearCustomerContext(); void load() })
|
||||
watch(() => form.direction, () => { if (!applicableItems.value.some((item) => item.id === form.feeItemId)) form.feeItemId = '' })
|
||||
watch(() => form.direction, (direction) => {
|
||||
if (!applicableItems.value.some((item) => item.id === form.feeItemId)) form.feeItemId = ''
|
||||
if (direction === 'RECEIVABLE') { form.partnerId = null; form.partnerName = null; partnersError.value = null }
|
||||
})
|
||||
onMounted(() => void load())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="fees-panel surface-panel" aria-label="柜次费用维护">
|
||||
<header class="fees-header"><div><h2>柜次费用</h2><p>费用与财务分摊均来自服务端真实记录</p></div><el-button v-if="canEdit && accountId" type="primary" @click="openCreate"><Plus :size="16" />新增费用</el-button></header>
|
||||
<header class="fees-header"><div><h2>柜次费用</h2><p>费用与财务分摊均来自服务端真实记录</p></div><el-button v-if="canEdit && canViewFeeItems && accountId" type="primary" @click="openCreate"><Plus :size="16" />新增费用</el-button></header>
|
||||
<el-alert v-if="!canView" type="warning" :closable="false" show-icon title="当前账号没有查看柜次费用的权限" />
|
||||
<template v-else>
|
||||
<div v-if="customerOptions.length > 1" class="customer-context"><span>客户</span><el-select v-model="accountId" placeholder="请选择费用归属客户"><el-option v-for="item in customerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></div>
|
||||
<el-alert v-if="customerOptions.length === 0" type="warning" :closable="false" show-icon title="装柜明细缺少客户归属,无法安全维护费用" />
|
||||
<el-alert v-else-if="!accountId" type="info" :closable="false" show-icon title="该柜次包含多个客户,请先选择费用归属客户" />
|
||||
<el-alert v-else-if="!canViewFeeItems" type="warning" :closable="false" show-icon title="当前账号缺少费用项目查看权限,无法新增或调整费用" />
|
||||
<LoadErrorState v-else-if="error" compact title="柜次费用加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<DataTable v-else :data="fees" :columns="columns" :loading="loading" preference-key="container-fees" row-key="id" empty-text="当前客户暂无柜次费用">
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="success" @click="approve(row as ContainerFeeResource)"><Check :size="15" />通过</el-button><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="danger" @click="openReject(row as ContainerFeeResource)"><CircleX :size="15" />驳回</el-button><el-button v-if="canEdit && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE'" link type="primary" @click="openAdjust(row as ContainerFeeResource)"><Pencil :size="15" />调整</el-button><el-button v-if="canReview && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE'" link type="danger" @click="voidFee(row as ContainerFeeResource)"><Trash2 :size="15" />作废</el-button></div></template>
|
||||
<template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : 'warning'" effect="plain">{{ row.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-tag></template>
|
||||
<template #partner="{ row }">{{ row.direction === 'PAYABLE' ? row.partnerName || '服务端未返回' : selectedCustomer?.label || '客户信息缺失' }}</template>
|
||||
<template #amount="{ row }">{{ money(row.amount) }}</template><template #reviewStatus="{ row }"><StatusBadge :status="row.reviewStatus === 'PENDING' ? '待审核' : row.reviewStatus === 'APPROVED' ? '已通过' : '已驳回'" /></template><template #sourceStatus="{ row }"><StatusBadge :status="row.sourceStatus === 'ACTIVE' ? '有效' : '已作废'" /></template><template #settlementIncluded="{ row }">{{ row.settlementIncluded ? '是' : '否' }}</template>
|
||||
</DataTable>
|
||||
|
||||
<div v-if="accountId" class="allocation-section"><header><div><h3>财务体积分摊</h3><p>{{ selectedCustomer?.label }} · 仅展示 Finance 已持久化结果</p></div></header><el-empty v-if="!loading && costs.length === 0" description="尚无与该柜次关联的财务分摊结果" :image-size="64" /><article v-for="cost in costs" :key="cost.id" class="cost-block"><div class="cost-summary"><strong>{{ cost.feeItemName || '柜次成本' }}</strong><span>往来单位:{{ cost.supplierName || '服务端未返回' }}</span><span>金额:¥{{ money(cost.amount) }}</span></div><DataTable :data="cost.allocations" :columns="allocationColumns" :preference-key="`container-cost-${cost.id}`" row-key="id" empty-text="服务端未返回分摊明细"><template #volume="{ row }">{{ row.volumeCbm.toFixed(3) }}</template><template #rate="{ row }">{{ (row.allocationRate * 100).toFixed(2) }}%</template><template #allocated="{ row }">{{ money(row.allocatedAmount) }}</template></DataTable></article></div>
|
||||
</template>
|
||||
|
||||
<AppDialog v-model="editorOpen" :title="target ? '调整已审核费用' : '新增柜次费用'" confirm-text="保存" :confirm-loading="saving" @confirm="save"><el-alert v-if="target" type="info" :closable="false" show-icon title="调整后版本递增并重新进入待审核状态" /><el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="fee-form"><el-form-item label="方向" prop="direction"><el-segmented v-model="form.direction" :options="[{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE' }]" /></el-form-item><el-form-item label="费用项目" prop="feeItemId"><el-select v-model="form.feeItemId" filterable placeholder="请选择启用且适用的费用项目"><el-option v-for="item in applicableItems" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item><el-form-item label="业务日期" prop="businessDate"><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="金额(CNY)" prop="amount"><el-input-number v-model="form.amount" :min="0" :precision="2" controls-position="right" /></el-form-item><el-form-item label="参与结算"><el-switch v-model="form.settlementIncluded" /></el-form-item><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form></AppDialog>
|
||||
<AppDialog v-model="editorOpen" :title="target ? '调整已审核费用' : '新增柜次费用'" confirm-text="保存" :confirm-loading="saving" @confirm="save"><el-alert v-if="target" type="info" :closable="false" show-icon title="调整后版本递增并重新进入待审核状态" /><el-alert v-if="!canViewPartners" type="warning" :closable="false" show-icon title="当前账号缺少往来单位查看权限,仅可维护应收费用" /><el-alert v-else-if="partnersError" type="warning" :closable="false" show-icon :title="partnersError.message" /><el-alert v-if="feeItemsError" type="warning" :closable="false" show-icon :title="feeItemsError.message" /><el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="fee-form"><el-form-item label="方向" prop="direction"><el-segmented v-model="form.direction" :options="[{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE', disabled: !canViewPartners || !!partnersError }]" /></el-form-item><el-form-item label="费用项目" prop="feeItemId"><el-select v-model="form.feeItemId" filterable :loading="feeItemsLoading" :disabled="!canViewFeeItems" placeholder="请选择启用且适用的费用项目"><el-option v-for="item in applicableItems" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item><el-form-item v-if="form.direction === 'PAYABLE'" label="往来单位" required><el-select v-model="form.partnerId" filterable :loading="partnersLoading" :disabled="!canViewPartners || !!partnersError" placeholder="请选择启用的往来单位"><el-option v-for="partner in partners" :key="partner.id" :label="partner.partnerName" :value="partner.id" /></el-select></el-form-item><el-form-item label="业务日期" prop="businessDate"><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="金额(CNY)" prop="amount"><el-input-number v-model="form.amount" :min="0" :precision="2" controls-position="right" /></el-form-item><el-form-item label="参与结算"><el-switch v-model="form.settlementIncluded" /></el-form-item><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form></AppDialog>
|
||||
<AppDialog v-model="rejectOpen" title="驳回柜次费用" confirm-text="确认驳回" :confirm-loading="saving" @confirm="reject"><el-input v-model="rejection" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入驳回原因" /></AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -27,6 +29,7 @@ const pending: InboundFeeDetail = {
|
||||
reviewReason: null, reviewerName: null, reviewedAt: null, publicationStatus: 'DRAFT', settlementIncluded: true,
|
||||
}
|
||||
const approved: InboundFeeDetail = { ...pending, id: '12', sourceFeeVersion: 3, reviewStatus: 'APPROVED', publicationStatus: 'PUBLISHED' }
|
||||
const source = readFileSync(join(process.cwd(), 'src/modules/unified-orders/components/InboundFeeDetailManager.vue'), 'utf8')
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
@@ -136,9 +139,19 @@ describe('InboundFeeDetailManager', () => {
|
||||
await flushPromises()
|
||||
expect(listInboundFeeItemOptions).toHaveBeenCalledTimes(1)
|
||||
expect(listInboundFeePartnerOptions).toHaveBeenCalledTimes(1)
|
||||
expect(document.body.textContent).toContain('往来单位不可用')
|
||||
expect(document.body.textContent).toContain('当前账号无权访问该数据')
|
||||
expect(document.body.textContent).toContain('A0403')
|
||||
expect(document.body.textContent).toContain('request-partner')
|
||||
expect(document.body.textContent).toContain('trace-partner')
|
||||
expect(document.querySelector('.fee-form .el-select')?.classList.contains('is-disabled')).toBe(false)
|
||||
})
|
||||
|
||||
it('refreshes fee rows and the order detail after every successful write', () => {
|
||||
expect(source).toContain("async function finishAction(message: string) {\n await load(); emit('changed')")
|
||||
expect(source).toMatch(/createInboundFeeDetail[\s\S]*updateInboundFeeDetail[\s\S]*adjustInboundFeeDetail[\s\S]*await finishAction\(/)
|
||||
expect(source).toMatch(/deleteInboundFeeDetail[\s\S]*await finishAction\('费用明细已删除'\)/)
|
||||
expect(source).toMatch(/reviewInboundFeeDetail[\s\S]*await finishAction\('费用审核通过'\)/)
|
||||
expect(source).toMatch(/reviewInboundFeeDetail[\s\S]*rejectReason[\s\S]*await finishAction\('费用已驳回'\)/)
|
||||
expect(source).toMatch(/voidInboundFeeDetail[\s\S]*await finishAction\('费用明细已作废'\)/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -15,6 +17,8 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import LocationsView from './LocationsView.vue'
|
||||
|
||||
const locationsSource = readFileSync(resolve(process.cwd(), 'src/modules/warehouse-resources/locations/pages/LocationsView.vue'), 'utf8')
|
||||
|
||||
vi.mock('../../../warehouses/api/platformWarehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('../api/platformLocations', () => ({
|
||||
listPlatformLocations: vi.fn(),
|
||||
@@ -138,6 +142,12 @@ enableAutoUnmount(afterEach)
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('LocationsView', () => {
|
||||
it('uses the prototype-sized centered editor while retaining the detail drawer', () => {
|
||||
expect(locationsSource).toContain("drawerMode.value === 'view' ? AppDrawer : AppDialog")
|
||||
expect(locationsSource).toContain("width: 'min(560px, calc(100vw - 28px))'")
|
||||
expect(locationsSource).toContain(':global(.location-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }')
|
||||
expect(locationsSource).toContain('.drawer-form :deep(.form-grid.two-cols) { grid-template-columns: minmax(0, 1fr); }')
|
||||
})
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
document.body.innerHTML = ''
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRe
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
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 FormSection from '@/shared/components/FormSection.vue'
|
||||
@@ -149,6 +150,10 @@ const domesticWarehouses = computed(() => warehouses.value.filter((warehouse) =>
|
||||
const editorOpen = computed(() => drawerOpen.value && drawerMode.value !== 'view')
|
||||
const editorTitle = computed(() => drawerMode.value === 'create' ? '新增库位' : '编辑库位')
|
||||
const drawerTitle = computed(() => drawerMode.value === 'view' ? '库位详情' : editorTitle.value)
|
||||
const drawerContainer = computed(() => drawerMode.value === 'view' ? AppDrawer : AppDialog)
|
||||
const drawerContainerProps = computed(() => drawerMode.value === 'view'
|
||||
? { size: 'min(620px, 94vw)', showConfirm: false }
|
||||
: { width: 'min(560px, calc(100vw - 28px))', showConfirm: false, destroyOnClose: true })
|
||||
const editorPermission = computed(() => drawerMode.value === 'create' ? 'platform:location:create' : 'platform:location:edit')
|
||||
const selectionDisabled = computed(() => drawerOpen.value || saving.value || actionLoadingIds.value.size > 0)
|
||||
const filtered = computed(() => Object.values(appliedFilters.value).some((value) => String(value || '').trim()))
|
||||
@@ -646,14 +651,14 @@ onMounted(() => {
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDrawer
|
||||
<component
|
||||
:is="drawerContainer"
|
||||
v-model="drawerOpen"
|
||||
:title="drawerTitle"
|
||||
size="min(620px, 94vw)"
|
||||
class="location-drawer"
|
||||
v-bind="drawerContainerProps"
|
||||
:class="drawerMode === 'view' ? 'location-drawer' : 'location-editor-dialog'"
|
||||
:loading="drawerLoading"
|
||||
:confirm-loading="saving"
|
||||
:show-confirm="false"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="!saving"
|
||||
:show-close="!saving"
|
||||
@@ -785,7 +790,7 @@ onMounted(() => {
|
||||
<el-button v-if="drawerMode !== 'view'" type="primary" :loading="saving" @click="saveLocation"><Save :size="16" />保存库位</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</AppDrawer>
|
||||
</component>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
@@ -801,6 +806,9 @@ onMounted(() => {
|
||||
.location-cell small, .stacked-value small { color: var(--cc-muted); font-size: 11px; }
|
||||
.remark-cell { display: -webkit-box; overflow: hidden; line-height: 1.5; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
|
||||
.drawer-content { min-height: 260px; }
|
||||
:global(.location-editor-dialog) { max-height: min(760px, calc(100vh - 32px)); display: flex; flex-direction: column; }
|
||||
:global(.location-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }
|
||||
:global(.location-editor-dialog .el-dialog__footer) { flex: 0 0 auto; }
|
||||
.drawer-content > .el-alert + .el-alert, .detail-descriptions + .el-alert { margin-top: 14px; }
|
||||
.detail-identity { display: grid; grid-template-columns: 48px minmax(0, 1fr) auto; align-items: center; gap: 12px; margin-bottom: 18px; }
|
||||
.detail-identity__icon { display: grid; width: 48px; height: 48px; place-items: center; border-radius: 10px; background: #eef3fa; color: var(--cc-primary); }
|
||||
@@ -820,6 +828,7 @@ onMounted(() => {
|
||||
:global(.location-action-menu .location-action-menu__danger:not(.is-disabled)) { color: var(--el-color-danger); }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.drawer-form :deep(.form-grid.two-cols) { grid-template-columns: minmax(0, 1fr); }
|
||||
.detail-identity { grid-template-columns: 44px minmax(0, 1fr); }
|
||||
.detail-identity > .el-tag { grid-column: 2; justify-self: start; }
|
||||
.detail-descriptions :deep(.el-descriptions__body), .detail-descriptions :deep(table), .detail-descriptions :deep(tbody) { display: block; width: 100%; }
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -15,6 +17,8 @@ import {
|
||||
} from '../api/platformPallets'
|
||||
import PalletsView from './PalletsView.vue'
|
||||
|
||||
const palletsSource = readFileSync(resolve(process.cwd(), 'src/modules/warehouse-resources/pallets/pages/PalletsView.vue'), 'utf8')
|
||||
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('../api/platformPallets', () => ({
|
||||
listPlatformPallets: vi.fn(),
|
||||
@@ -108,6 +112,13 @@ enableAutoUnmount(afterEach)
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('PalletsView', () => {
|
||||
it('uses the prototype-sized centered editor while retaining the detail drawer', () => {
|
||||
expect(palletsSource).toContain('<AppDrawer v-model="detailOpen"')
|
||||
expect(palletsSource).toContain('<AppDialog v-model="editorOpen" :title="editorTitle" width="min(560px, calc(100vw - 28px))"')
|
||||
expect(palletsSource).not.toContain('<el-drawer v-model="editorOpen"')
|
||||
expect(palletsSource).toContain(':global(.pallet-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }')
|
||||
expect(palletsSource).toContain('.editor-field-grid { grid-template-columns: minmax(0, 1fr); }')
|
||||
})
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
document.body.innerHTML = ''
|
||||
@@ -272,12 +283,9 @@ describe('PalletsView', () => {
|
||||
await createButton?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.querySelector('.pallet-editor-header')?.textContent).toContain('新增托盘')
|
||||
expect(document.body.querySelector('.pallet-editor-header')?.textContent).toContain('录入归属仓库与基础规格')
|
||||
const editorDialog = document.body.querySelector('.pallet-editor-drawer[role="dialog"]')
|
||||
const labelledBy = editorDialog?.getAttribute('aria-labelledby')
|
||||
expect(labelledBy).toBeTruthy()
|
||||
expect(document.getElementById(labelledBy || '')?.textContent).toBe('新增托盘')
|
||||
const editorDialog = document.body.querySelector('.pallet-editor-dialog')
|
||||
expect(editorDialog?.textContent).toContain('新增托盘')
|
||||
expect(editorDialog?.querySelector('.el-dialog__title')?.textContent).toBe('新增托盘')
|
||||
expect(document.body.querySelectorAll('.editor-section')).toHaveLength(2)
|
||||
expect(document.body.querySelector('#pallet-ownership-heading')?.textContent).toBe('归属仓库')
|
||||
expect(document.body.querySelector('#pallet-spec-heading')?.textContent).toBe('托盘规格')
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRe
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import FormSection from '@/shared/components/FormSection.vue'
|
||||
@@ -474,16 +475,7 @@ onMounted(() => {
|
||||
<template #footer><el-button @click="detailOpen = false">关闭</el-button></template>
|
||||
</AppDrawer>
|
||||
|
||||
<el-drawer v-model="editorOpen" size="560px" class="business-drawer pallet-editor-drawer" :close-on-click-modal="false">
|
||||
<template #header="{ titleId, titleClass }">
|
||||
<div class="pallet-editor-header">
|
||||
<span aria-hidden="true"><Boxes :size="20" /></span>
|
||||
<div>
|
||||
<h2 :id="titleId" :class="titleClass">{{ editorTitle }}</h2>
|
||||
<p>{{ form.id ? '调整托盘规格与现场识别信息' : '录入归属仓库与基础规格,创建后即可用于仓储作业' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<AppDialog v-model="editorOpen" :title="editorTitle" width="min(560px, calc(100vw - 28px))" class="pallet-editor-dialog" :close-on-click-modal="false" :confirm-loading="saving" :show-confirm="false" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="editor-form">
|
||||
<FormSection class="editor-section" title="归属仓库" description="托盘创建后固定归属于所选仓库,不支持跨仓移动。" aria-labelledby="pallet-ownership-heading">
|
||||
<span id="pallet-ownership-heading" class="sr-only">归属仓库</span>
|
||||
@@ -543,7 +535,7 @@ onMounted(() => {
|
||||
<div><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="savePallet"><Save :size="16" />保存托盘</el-button></div>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
@@ -569,10 +561,9 @@ onMounted(() => {
|
||||
.detail-hero p { margin-top: 4px; color: var(--cc-muted); font-size: 13px; }
|
||||
.detail-section-title { margin: 20px 0 8px; color: var(--cc-text); font-size: 14px; }
|
||||
.detail-section-title:first-of-type { margin-top: 0; }
|
||||
.pallet-editor-header { display: flex; min-width: 0; align-items: center; gap: 12px; }
|
||||
.pallet-editor-header > span { display: grid; width: 38px; height: 38px; flex: 0 0 auto; place-items: center; border-radius: 8px; background: var(--el-color-primary-light-9); color: var(--cc-primary); }
|
||||
.pallet-editor-header h2 { margin: 0; color: #172033; font-size: 17px; line-height: 1.4; }
|
||||
.pallet-editor-header p { margin: 3px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||
:global(.pallet-editor-dialog) { max-height: min(760px, calc(100vh - 32px)); display: flex; flex-direction: column; }
|
||||
:global(.pallet-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }
|
||||
:global(.pallet-editor-dialog .el-dialog__footer) { flex: 0 0 auto; }
|
||||
.editor-form { padding: 4px 24px 28px; }
|
||||
.editor-section { padding: 20px 0 4px; border-bottom: 1px solid var(--cc-border); }
|
||||
.editor-section:last-child { padding-bottom: 0; border-bottom: 0; }
|
||||
@@ -588,6 +579,9 @@ onMounted(() => {
|
||||
.editor-form :deep(.el-input-number), .editor-form :deep(.el-select) { width: 100%; }
|
||||
.editor-form :deep(.el-form-item) { margin-bottom: 18px; }
|
||||
.editor-form :deep(.el-form-item__label) { padding-bottom: 7px; color: #344054; font-size: 12px; font-weight: 650; line-height: 1.4; }
|
||||
@media (max-width: 720px) {
|
||||
.editor-field-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
.editor-remark-field { margin-bottom: 0 !important; }
|
||||
.field-help { margin: 6px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||
.drawer-footer { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -19,6 +21,8 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import WarehousesView from './WarehousesView.vue'
|
||||
|
||||
const warehousesSource = readFileSync(resolve(process.cwd(), 'src/modules/warehouses/pages/WarehousesView.vue'), 'utf8')
|
||||
|
||||
vi.mock('@/modules/warehouses/api/platformWarehouses', () => ({
|
||||
listPlatformWarehouses: vi.fn(),
|
||||
getPlatformWarehouse: vi.fn(),
|
||||
@@ -171,6 +175,12 @@ enableAutoUnmount(afterEach)
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('WarehousesView', () => {
|
||||
it('uses the prototype-sized centered warehouse editor with a fixed footer', () => {
|
||||
expect(warehousesSource).toContain('<AppDialog v-model="editorOpen" :title="editorLabel" width="min(650px, calc(100vw - 28px))"')
|
||||
expect(warehousesSource).not.toContain('<AppDrawer v-model="editorOpen"')
|
||||
expect(warehousesSource).toContain(':global(.warehouse-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }')
|
||||
expect(warehousesSource).toContain('.drawer-form :deep(.form-grid.two-cols) { grid-template-columns: minmax(0, 1fr); }')
|
||||
})
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
document.body.innerHTML = ''
|
||||
@@ -263,12 +273,12 @@ describe('WarehousesView', () => {
|
||||
wrapper.findComponent({ name: 'ElDropdown' }).vm.$emit('command', 'FBA')
|
||||
await flushPromises()
|
||||
|
||||
const drawerText = document.querySelector('.el-drawer')?.textContent || ''
|
||||
expect(drawerText).toContain('新增FBA 地址')
|
||||
expect(drawerText).toContain('国家或地区')
|
||||
expect(drawerText).not.toContain('时区覆盖')
|
||||
expect(drawerText).not.toContain('联系人')
|
||||
expect(drawerText).not.toContain('收货时间')
|
||||
const dialogText = document.querySelector('.warehouse-editor-dialog')?.textContent || ''
|
||||
expect(dialogText).toContain('新增FBA 地址')
|
||||
expect(dialogText).toContain('国家或地区')
|
||||
expect(dialogText).not.toContain('时区覆盖')
|
||||
expect(dialogText).not.toContain('联系人')
|
||||
expect(dialogText).not.toContain('收货时间')
|
||||
const countrySelect = wrapper.findAllComponents({ name: 'ElSelect' })
|
||||
.find((select) => select.props('placeholder') === '美国 US')
|
||||
expect(countrySelect).toBeDefined()
|
||||
@@ -309,10 +319,10 @@ describe('WarehousesView', () => {
|
||||
wrapper.findComponent({ name: 'ElDropdown' }).vm.$emit('command', 'DOMESTIC')
|
||||
await flushPromises()
|
||||
|
||||
const drawerText = document.querySelector('.el-drawer')?.textContent || ''
|
||||
expect(drawerText).not.toContain('国家或地区')
|
||||
expect(drawerText).not.toContain('时区')
|
||||
expect(drawerText).not.toContain('仓库类型')
|
||||
const dialogText = document.querySelector('.warehouse-editor-dialog')?.textContent || ''
|
||||
expect(dialogText).not.toContain('国家或地区')
|
||||
expect(dialogText).not.toContain('时区')
|
||||
expect(dialogText).not.toContain('仓库类型')
|
||||
|
||||
setField('input[placeholder="请输入仓库名称"]', '义乌新仓')
|
||||
setField('input[placeholder="例如 浙江省"]', '浙江省')
|
||||
@@ -384,10 +394,10 @@ describe('WarehousesView', () => {
|
||||
await editButton.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const drawerText = document.querySelector('.el-drawer')?.textContent || ''
|
||||
expect(drawerText).toContain('编辑FBA 地址')
|
||||
expect(drawerText).not.toContain('例如 US')
|
||||
expect(drawerText).not.toContain('例如 UTC-8')
|
||||
const dialogText = document.querySelector('.warehouse-editor-dialog')?.textContent || ''
|
||||
expect(dialogText).toContain('编辑FBA 地址')
|
||||
expect(dialogText).not.toContain('例如 US')
|
||||
expect(dialogText).not.toContain('例如 UTC-8')
|
||||
|
||||
const countrySelect = wrapper.findAllComponents({ name: 'ElSelect' })
|
||||
.find((select) => select.props('placeholder') === '请选择国家或地区')
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
||||
import { ChevronDown, Download, FileSpreadsheet, FileUp, MapPin, Pencil, Plus, Power, Save, Warehouse as WarehouseIcon } from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import FormSection from '@/shared/components/FormSection.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
@@ -733,7 +733,7 @@ onMounted(() => void loadWarehouses())
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDrawer v-model="editorOpen" :title="editorLabel" size="min(620px, 94vw)" class="editor-drawer" :close-on-click-modal="false" :confirm-loading="saving" :show-confirm="false">
|
||||
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(650px, calc(100vw - 28px))" class="warehouse-editor-dialog" :close-on-click-modal="false" :confirm-loading="saving" :show-confirm="false" destroy-on-close>
|
||||
<el-form :model="form" label-position="top" class="drawer-form" :disabled="saving">
|
||||
<FormSection title="基础信息">
|
||||
<div class="form-grid two-cols">
|
||||
@@ -790,7 +790,7 @@ onMounted(() => void loadWarehouses())
|
||||
</FormSection>
|
||||
</el-form>
|
||||
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveWarehouse"><Save :size="16" />{{ editorMode === 'EDIT' ? '保存修改' : '确认新增' }}</el-button></div></template>
|
||||
</AppDrawer>
|
||||
</AppDialog>
|
||||
|
||||
<el-dialog v-model="importOpen" title="导入公共仓库" width="min(760px, calc(100vw - 32px))" :close-on-click-modal="!importing" :close-on-press-escape="!importing">
|
||||
<div class="import-content">
|
||||
@@ -839,7 +839,13 @@ onMounted(() => void loadWarehouses())
|
||||
.warehouse-address span { min-width: 0; }
|
||||
.field-error { width: 100%; margin: 6px 0 0; color: var(--el-color-danger); font-size: 12px; line-height: 1.35; overflow-wrap: anywhere; }
|
||||
.compact-grid { row-gap: 10px; }
|
||||
:global(.warehouse-editor-dialog) { max-height: min(800px, calc(100vh - 32px)); display: flex; flex-direction: column; }
|
||||
:global(.warehouse-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }
|
||||
:global(.warehouse-editor-dialog .el-dialog__footer) { flex: 0 0 auto; }
|
||||
.warehouse-status-toggle :deep(.el-radio-button__inner) { min-width: 74px; }
|
||||
@media (max-width: 720px) {
|
||||
.drawer-form :deep(.form-grid.two-cols) { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
.import-content { display: grid; gap: 20px; }
|
||||
.import-toolbar { justify-content: space-between; }
|
||||
.import-result { display: grid; gap: 14px; padding-top: 16px; border-top: 1px solid var(--cc-border); }
|
||||
|
||||
Reference in New Issue
Block a user