优化
This commit is contained in:
@@ -40,12 +40,13 @@
|
||||
| 工作台与业务总览 | `modules/dashboard/` | 按具体指标联查其所属业务模块和服务端,不在工作台复制领域契约 |
|
||||
| 客户、企业审核 | `modules/customers/` | `iam/`、`file/` |
|
||||
| 平台用户与 RBAC | `modules/iam/`、`core/access/` | `auth/`、`iam/` |
|
||||
| 导航与原型菜单图标 | `core/navigation/adminNavigation.ts`、`core/navigation/prototypeMenuIcons.ts` | 菜单结构、权限与路由同步核对;已有原型图标优先使用映射资源 |
|
||||
| 平台仓库与仓储资源 | `modules/warehouses/`、`modules/warehouse-resources/` | `warehouse/` |
|
||||
| 订单列表与详情编排 | `modules/unified-orders/`、`modules/first-leg-orders/` | `order/`、`firstleg/` |
|
||||
| 入库、测量和计费 | `modules/inbounds/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`file/`、`integration-service/` |
|
||||
| 订单列表、详情与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;费用入口 `modules/unified-orders/components/InboundFeeDetailManager.vue`、`modules/unified-orders/api/inboundFeeDetails.ts` | `order/`、`firstleg/`;费用来源审核发布联查 `firstleg/AGENTS.md` |
|
||||
| 入库、测量和计费工作流 | `modules/inbounds/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`file/`、`integration-service/` |
|
||||
| 财务账户、账单、收款与成本 | `modules/finance/` | `finance/`;费用来源与柜次成本快照联查 `firstleg/`,凭证联查 `file/` |
|
||||
| 货柜与智能排柜 | `modules/container-loading/` | `firstleg/loading/`、`firstleg/split/` |
|
||||
| 预装与装柜清单 | `modules/loading-lists/` | `firstleg/loading/`、`file/`、`warehouse/` |
|
||||
| 预装详情、装柜清单与柜次费用 | `modules/loading-lists/pages/PreloadPlanDetailView.vue`、`modules/loading-lists/components/LoadingListDetailDrawer.vue`、`modules/loading-lists/components/ContainerFeesPanel.vue`、`modules/loading-lists/api/containerFees.ts` | `firstleg/loading/`、`firstleg` 柜次费用、`finance/` 成本分摊、`file/`、`warehouse/` |
|
||||
| 海运订舱与船期 | `modules/ocean-bookings/`、`modules/port-schedules/` | `firstleg/oceanbooking/`、`firstleg/portschedule/`、`integration-service/` |
|
||||
| 拖车预约 | `modules/trucking/` | `firstleg/outbound/dispatch/`、`firstleg/` |
|
||||
| 报关协同 | `modules/customs/` | `firstleg/customs/`、`file/` |
|
||||
@@ -79,6 +80,7 @@
|
||||
|
||||
## UI 与交互
|
||||
|
||||
- 修改已有 Admin 原型页面前先读 `../../docs/Admin-Client前端原型还原验收规则.md`,按页面和操作形成原型核对矩阵;本节维护 Admin 的实现约束,不复制跨前端验收文档全文。
|
||||
- 已向客户讲解或确认过的产品原型是页面视觉与布局实现的直接事实来源。字段顺序、字段分组、字段摆放、列宽、控件宽高、对齐方式、换行方式、区块层级、上下左右边距、内边距、间隙、留白、圆角、边框、阴影、背景色、文字颜色和字号均必须按原型还原,不得只参考大致风格后自行重新排版。
|
||||
- 原型中的按钮位置、顺序、尺寸、圆角、边框、背景色、文字色、图标、悬停态、激活态、禁用态和危险语义必须直接按原型实现;不得擅自改为文字链接、不同颜色、不同按钮类型、折叠菜单或自创交互效果,也不得增加原型没有的上浮、缩放、渐变线、装饰图标或其他视觉元素。
|
||||
- 原型已经给出明确图标、字符图标、图片资源或按钮内图标时,直接使用原型中的同一方案和资源,不得以 Lucide、Element Plus 默认图标、手绘图标或个人偏好替换。仅当原型没有定义对应图标时,才按本节的默认图标语义补齐。
|
||||
|
||||
@@ -22,6 +22,58 @@ function createPrototypeMenuIcon(name: string, children: SvgChild[]): Component
|
||||
})
|
||||
}
|
||||
|
||||
function createOrderDetailIcon(name: string, children: SvgChild[]): Component {
|
||||
return defineComponent({
|
||||
name,
|
||||
inheritAttrs: false,
|
||||
props: { size: { type: [Number, String], default: 20 } },
|
||||
setup(props, { attrs }) {
|
||||
return () => h('svg', {
|
||||
...attrs,
|
||||
width: props.size,
|
||||
height: props.size,
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
'stroke-width': 1.8,
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
'aria-hidden': 'true',
|
||||
} satisfies SVGAttributes, children)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const OrderBasicInfoIcon = createOrderDetailIcon('OrderBasicInfoIcon', [
|
||||
h('path', { d: 'm12 3 8 4.5v9L12 21l-8-4.5v-9L12 3Z' }),
|
||||
h('path', { d: 'm4 7.5 8 4.5 8-4.5M12 12v9' }),
|
||||
])
|
||||
export const OrderCargoInfoIcon = createOrderDetailIcon('OrderCargoInfoIcon', [
|
||||
h('path', { d: 'm12 3 8 4.5v9L12 21l-8-4.5v-9L12 3Z' }),
|
||||
h('path', { d: 'm4 7.5 8 4.5 8-4.5M12 12v9' }),
|
||||
h('path', { d: 'M8 5.25 16 9.75' }),
|
||||
])
|
||||
export const OrderFeeInfoIcon = createOrderDetailIcon('OrderFeeInfoIcon', [
|
||||
h('rect', { x: 3, y: 5, width: 18, height: 14, rx: 2 }),
|
||||
h('path', { d: 'M3 9h18M7 14h4' }),
|
||||
])
|
||||
export const OrderHistoryIcon = createOrderDetailIcon('OrderHistoryIcon', [
|
||||
h('path', { d: 'M3 12a9 9 0 1 0 3-6.7' }),
|
||||
h('path', { d: 'M3 4v5h5M12 7v5l3 2' }),
|
||||
])
|
||||
export const OrderMeasurementIcon = createOrderDetailIcon('OrderMeasurementIcon', [
|
||||
h('path', { d: 'm4 17 13-13 3 3L7 20H4v-3Z' }),
|
||||
h('path', { d: 'm13 8 3 3M7 17l-1 1M10 14l-1 1M13 11l-1 1' }),
|
||||
])
|
||||
export const OrderOtherIcon = createOrderDetailIcon('OrderOtherIcon', [
|
||||
h('path', { d: 'M6 3h9l4 4v14H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z' }),
|
||||
h('path', { d: 'M14 3v5h5' }),
|
||||
])
|
||||
export const OrderFulfillmentIcon = createOrderDetailIcon('OrderFulfillmentIcon', [
|
||||
h('path', { d: 'M4 6h2M4 12h2M4 18h2M9 6h11M9 12h11M9 18h11' }),
|
||||
h('path', { d: 'm7 11 1.5 1.5L11 10M7 17l1.5 1.5L11 16' }),
|
||||
])
|
||||
|
||||
export const PrototypeDashboardIcon = createPrototypeMenuIcon('PrototypeDashboardIcon', [
|
||||
h('rect', { x: 3, y: 3, width: 7, height: 8, rx: 1.5, fill: '#1677ff' }),
|
||||
h('rect', { x: 14, y: 3, width: 7, height: 5, rx: 1.5, fill: '#1677ff', opacity: 0.6 }),
|
||||
|
||||
@@ -1,10 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
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 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() }
|
||||
})
|
||||
|
||||
const panel = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/ContainerFeesPanel.vue'), 'utf8')
|
||||
const detail = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/LoadingListDetailDrawer.vue'), 'utf8')
|
||||
|
||||
class ResizeObserverStub { observe() {}; unobserve() {}; disconnect() {} }
|
||||
function memoryStorage(): 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 mountPanel(permissions: string[], customers = [{ accountId: '11', customerName: '客户A' }]) {
|
||||
const pinia = createPinia(); setActivePinia(pinia)
|
||||
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
const wrapper = mount(ContainerFeesPanel, { attachTo: document.body, props: { planId: '9', customers }, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises(); return wrapper
|
||||
}
|
||||
|
||||
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(listContainerCostDetails).mockReset().mockResolvedValue([]); vi.mocked(createContainerFee).mockReset()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('ContainerFeesPanel contract', () => {
|
||||
it('is exposed from loading-list detail and derives customer context from real items', () => {
|
||||
expect(detail).toContain('<ContainerFeesPanel :plan-id="summary.planId" :customers="feeCustomers" />')
|
||||
@@ -31,4 +60,38 @@ describe('ContainerFeesPanel contract', () => {
|
||||
expect(panel).toContain('尚无与该柜次关联的财务分摊结果')
|
||||
expect(panel).not.toMatch(/allocatedAmount\s*=|allocationRate\s*=/)
|
||||
})
|
||||
|
||||
it('clears the previous customer context and ignores a late response', async () => {
|
||||
let resolveFirst!: (value: Awaited<ReturnType<typeof listContainerFees>>) => void
|
||||
vi.mocked(listContainerFees).mockImplementation((planId, accountId) => accountId === '11'
|
||||
? new Promise((resolve) => { resolveFirst = resolve })
|
||||
: Promise.resolve([]))
|
||||
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view'], [{ accountId: '11', customerName: '客户A' }, { accountId: '22', customerName: '客户B' }])
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: { accountId: string; fees: unknown[]; costs: unknown[]; editorOpen: boolean } }).setupState
|
||||
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 }])
|
||||
await flushPromises()
|
||||
expect(state.fees).toEqual([])
|
||||
expect(listContainerCostDetails).not.toHaveBeenCalledWith('11', expect.anything())
|
||||
})
|
||||
|
||||
it('blocks write entry points without a selected customer or edit permission', async () => {
|
||||
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view'], [{ accountId: '11', customerName: '客户A' }, { accountId: '22', customerName: '客户B' }])
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; save(): Promise<void>; editorOpen: boolean } }).setupState
|
||||
state.openCreate(); await state.save()
|
||||
expect(state.editorOpen).toBe(false)
|
||||
expect(createContainerFee).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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 },
|
||||
])
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,6 +26,7 @@ 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 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'))
|
||||
@@ -67,21 +68,28 @@ function resetForm(item?: ContainerFeeResource) {
|
||||
|
||||
async function load() {
|
||||
if (!accountId.value || !canView.value) return
|
||||
const sequence = ++loadSequence
|
||||
const requestedAccountId = accountId.value
|
||||
loading.value = true; error.value = null
|
||||
try {
|
||||
const [feeRows, items] = await Promise.all([listContainerFees(props.planId, accountId.value), listApplicableFeeItems()])
|
||||
const [feeRows, items] = await Promise.all([listContainerFees(props.planId, requestedAccountId), listApplicableFeeItems()])
|
||||
if (sequence !== loadSequence || requestedAccountId !== accountId.value) return
|
||||
fees.value = feeRows; feeItems.value = items
|
||||
costs.value = []
|
||||
const currentContainerId = feeRows.find((item) => item.containerBookingId)?.containerBookingId
|
||||
if (currentContainerId) costs.value = await listContainerCostDetails(accountId.value, currentContainerId)
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '柜次费用加载失败') }
|
||||
finally { loading.value = false }
|
||||
if (currentContainerId) {
|
||||
const result = await listContainerCostDetails(requestedAccountId, currentContainerId)
|
||||
if (sequence !== loadSequence || requestedAccountId !== accountId.value) return
|
||||
costs.value = result
|
||||
}
|
||||
} catch (cause) { if (sequence === loadSequence && requestedAccountId === accountId.value) error.value = getApiErrorDetails(cause, '柜次费用加载失败') }
|
||||
finally { if (sequence === loadSequence) loading.value = false }
|
||||
}
|
||||
|
||||
function openCreate() { resetForm(); editorOpen.value = true }
|
||||
function openAdjust(item: ContainerFeeResource) { resetForm(item); editorOpen.value = true }
|
||||
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 }
|
||||
async function save() {
|
||||
if (!accountId.value || !await formRef.value?.validate().catch(() => false)) return
|
||||
if (!canEdit.value || !accountId.value || !await formRef.value?.validate().catch(() => false)) return
|
||||
saving.value = true
|
||||
try {
|
||||
if (target.value) await adjustContainerFee(props.planId, accountId.value, target.value.id, target.value.sourceVersion, { ...form })
|
||||
@@ -90,22 +98,30 @@ async function save() {
|
||||
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '费用保存失败').message) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
async function approve(item: ContainerFeeResource) { await approveContainerFee(props.planId, accountId.value, item.id).then(() => ElMessage.success('费用已审核通过')).then(load).catch((cause) => ElMessage.error(getApiErrorDetails(cause, '审核失败').message)) }
|
||||
function openReject(item: ContainerFeeResource) { target.value = item; rejection.value = ''; rejectOpen.value = true }
|
||||
async function approve(item: ContainerFeeResource) { if (!canReview.value || !accountId.value) return; await approveContainerFee(props.planId, accountId.value, item.id).then(() => ElMessage.success('费用已审核通过')).then(load).catch((cause) => ElMessage.error(getApiErrorDetails(cause, '审核失败').message)) }
|
||||
function openReject(item: ContainerFeeResource) { if (!canReview.value || !accountId.value) return; target.value = item; rejection.value = ''; rejectOpen.value = true }
|
||||
async function reject() {
|
||||
if (!target.value || !rejection.value.trim()) return ElMessage.warning('请输入驳回原因')
|
||||
if (!canReview.value || !accountId.value || !target.value) return
|
||||
if (!rejection.value.trim()) return ElMessage.warning('请输入驳回原因')
|
||||
saving.value = true
|
||||
try { await rejectContainerFee(props.planId, accountId.value, target.value.id, rejection.value.trim()); rejectOpen.value = false; ElMessage.success('费用已驳回'); await load() }
|
||||
catch (cause) { ElMessage.error(getApiErrorDetails(cause, '驳回失败').message) } finally { saving.value = false }
|
||||
}
|
||||
async function voidFee(item: ContainerFeeResource) {
|
||||
if (!canReview.value || !accountId.value) return
|
||||
await ElMessageBox.confirm(`确认作废“${item.feeItemName}”费用?作废后将同步财务来源状态。`, '作废费用', { type: 'warning', confirmButtonText: '确认作废' })
|
||||
await voidContainerFee(props.planId, accountId.value, item.id, item.sourceVersion).then(() => ElMessage.success('费用已作废')).then(load).catch((cause) => ElMessage.error(getApiErrorDetails(cause, '作废失败').message))
|
||||
}
|
||||
function money(value: number) { return value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }
|
||||
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
|
||||
editorOpen.value = false; rejectOpen.value = false; target.value = null; rejection.value = ''
|
||||
resetForm()
|
||||
}
|
||||
watch(customerOptions, initializeCustomer, { immediate: true })
|
||||
watch(accountId, () => { fees.value = []; costs.value = []; void load() })
|
||||
watch(accountId, () => { clearCustomerContext(); void load() })
|
||||
watch(() => form.direction, () => { if (!applicableItems.value.some((item) => item.id === form.feeItemId)) form.feeItemId = '' })
|
||||
onMounted(() => void load())
|
||||
</script>
|
||||
|
||||
@@ -40,4 +40,26 @@ describe('OceanBookingDetailView interaction contract', () => {
|
||||
'例如:PREPAID', '请输入订舱备注(可选)',
|
||||
]) expect(source).toContain(`placeholder="${placeholder}"`)
|
||||
})
|
||||
|
||||
it('keeps view, create and edit on the formal detail route with a return action', () => {
|
||||
expect(source).toContain("router.push('/ocean-bookings')")
|
||||
expect(source).toContain("router.replace(`/ocean-bookings/${created.id}`)")
|
||||
expect(source).not.toContain('AppDrawer')
|
||||
expect(source).not.toContain('打印')
|
||||
})
|
||||
|
||||
it('groups real detail fields into the frozen three-column prototype structure', () => {
|
||||
expect(source).toContain('aria-label="基本信息"')
|
||||
expect(source).toContain('aria-label="航线船期"')
|
||||
expect(source).toContain('aria-label="条款与柜量"')
|
||||
expect(source).toContain('grid-template-columns:minmax(0,1.4fr) repeat(2,minmax(0,1fr))')
|
||||
expect(source).toContain('@media(max-width:720px){.booking-groups,.party-grid{grid-template-columns:1fr}')
|
||||
})
|
||||
|
||||
it('keeps the three real booking parties in their own three-column section', () => {
|
||||
expect(source).toContain("{ value: 'SHIPPER', label: '发货人' }")
|
||||
expect(source).toContain("{ value: 'CONSIGNEE', label: '收货人' }")
|
||||
expect(source).toContain("{ value: 'NOTIFY_PARTY', label: '通知人' }")
|
||||
expect(source).toContain('.party-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr))')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -249,11 +249,19 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
</div>
|
||||
</div>
|
||||
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
|
||||
<el-form ref="formRef" :model="form.data" :rules="rules" label-position="top" :disabled="!editable" class="form-grid">
|
||||
<el-form ref="formRef" :model="form.data" :rules="rules" label-position="top" :disabled="!editable" class="booking-groups">
|
||||
<section class="booking-group" aria-label="基本信息">
|
||||
<h3>基本信息</h3><div class="booking-group__body">
|
||||
<el-form-item label="订舱号"><el-input :model-value="detail?.bookingNo || '保存后生成'" disabled /></el-form-item>
|
||||
<el-form-item label="客户" prop="customerId"><el-select :model-value="form.data.customerId" filterable placeholder="请选择客户" :loading="optionsLoading" :disabled="!isNew" style="width:100%" @update:model-value="selectCustomer(String($event || ''))"><el-option v-for="customer in selectableCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="状态"><el-input :model-value="detail ? statusLabel(detail.status) : '待创建'" disabled /></el-form-item>
|
||||
<el-form-item label="提单号"><el-input v-model="form.data.billOfLadingNo" placeholder="请输入提单号(可选)" /></el-form-item>
|
||||
<el-form-item label="船公司"><el-select :model-value="form.data.shippingCompanyId" filterable clearable placeholder="请选择船公司" :loading="optionsLoading" style="width:100%" @update:model-value="selectPartner('shippingCompany', String($event || ''))"><el-option v-if="form.data.shippingCompanyId && !shippingCompanies.some((partner) => String(partner.id) === String(form.data.shippingCompanyId))" :label="form.data.shippingCompanyName || '当前船公司'" :value="String(form.data.shippingCompanyId)" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="订舱代理"><el-select :model-value="form.data.bookingAgentId" filterable clearable placeholder="请选择订舱代理" :loading="optionsLoading" style="width:100%" @update:model-value="selectPartner('bookingAgent', String($event || ''))"><el-option v-if="form.data.bookingAgentId && !bookingAgents.some((partner) => String(partner.id) === String(form.data.bookingAgentId))" :label="form.data.bookingAgentName || '当前订舱代理'" :value="String(form.data.bookingAgentId)" /><el-option v-for="partner in bookingAgents" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
<section class="booking-group" aria-label="航线船期">
|
||||
<h3>航线船期</h3><div class="booking-group__body">
|
||||
<el-form-item label="船名"><el-input v-model="form.data.vesselName" placeholder="请输入船名" /></el-form-item>
|
||||
<el-form-item label="航次"><el-input v-model="form.data.voyageNo" placeholder="请输入航次" /></el-form-item>
|
||||
<el-form-item label="起运港代码"><el-input v-model="form.data.departurePortCode" placeholder="例如:CNNGB" /></el-form-item>
|
||||
@@ -261,11 +269,24 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
<el-form-item label="目的港代码"><el-input v-model="form.data.destinationPortCode" placeholder="例如:USLAX" /></el-form-item>
|
||||
<el-form-item label="目的港"><el-input v-model="form.data.destinationPortName" placeholder="例如:洛杉矶港" /></el-form-item>
|
||||
<el-form-item label="交货地点"><el-input v-model="form.data.placeOfDelivery" placeholder="请输入最终交货地点" /></el-form-item>
|
||||
<el-form-item label="服务条款"><el-input v-model="form.data.serviceTerm" placeholder="例如:CY-CY" /></el-form-item>
|
||||
<el-form-item label="运费条款"><el-input v-model="form.data.freightTerm" placeholder="例如:PREPAID" /></el-form-item>
|
||||
<el-form-item label="预计开船"><el-date-picker v-model="form.data.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="预计到港"><el-date-picker v-model="form.data.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></el-form-item>
|
||||
<el-form-item class="wide" label="备注"><el-input v-model="form.data.remark" type="textarea" placeholder="请输入订舱备注(可选)" /></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
<section class="booking-group" aria-label="条款与柜量">
|
||||
<h3>条款与柜量</h3><div class="booking-group__body">
|
||||
<el-form-item label="服务条款"><el-input v-model="form.data.serviceTerm" placeholder="例如:CY-CY" /></el-form-item>
|
||||
<el-form-item label="运费条款"><el-input v-model="form.data.freightTerm" placeholder="例如:PREPAID" /></el-form-item>
|
||||
<div v-for="(container, index) in form.data.containerRequirements" :key="index" class="container-row">
|
||||
<el-input v-model="container.containerType" :disabled="!editable" placeholder="箱型,如 40HC" />
|
||||
<el-input v-model="container.containerKind" :disabled="!editable" placeholder="类型,如 FCL" />
|
||||
<el-input-number v-model="container.quantity" :disabled="!editable" :min="1" :max="999" />
|
||||
<el-button v-if="editable" type="danger" plain :disabled="form.data.containerRequirements.length === 1" @click="removeContainer(index)"><Trash2 :size="15" />删除</el-button>
|
||||
</div>
|
||||
<el-button v-if="editable" class="add-container" @click="addContainer"><Plus :size="16" />新增箱型</el-button>
|
||||
<el-form-item label="备注"><el-input v-model="form.data.remark" type="textarea" :rows="3" placeholder="请输入订舱备注(可选)" /></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
</el-form>
|
||||
</section>
|
||||
|
||||
@@ -282,16 +303,6 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="surface-panel">
|
||||
<div class="section-title"><h2>箱型需求</h2><el-button v-if="editable" @click="addContainer"><Plus :size="16" />新增箱型</el-button></div>
|
||||
<div v-for="(container, index) in form.data.containerRequirements" :key="index" class="container-row">
|
||||
<el-input v-model="container.containerType" :disabled="!editable" placeholder="箱型,如 40HC" />
|
||||
<el-input v-model="container.containerKind" :disabled="!editable" placeholder="类型,如 FCL" />
|
||||
<el-input-number v-model="container.quantity" :disabled="!editable" :min="1" :max="999" />
|
||||
<el-button v-if="editable" type="danger" plain :disabled="form.data.containerRequirements.length === 1" @click="removeContainer(index)"><Trash2 :size="15" />删除</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="detail" class="surface-panel history-panel">
|
||||
<div class="section-title"><div><h2>状态历史</h2><p>当前进度:{{ statusLabel(detail.status) }}</p></div></div>
|
||||
<el-timeline><el-timeline-item v-for="event in detail.histories" :key="event.id" :timestamp="event.occurredAt"><strong>{{ statusLabel(event.fromStatus) }} → {{ statusLabel(event.toStatus) }}</strong><p>{{ event.operatorName || '系统' }}<span v-if="event.reason"> · {{ event.reason }}</span></p></el-timeline-item></el-timeline>
|
||||
@@ -312,5 +323,5 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.booking-detail{min-width:0}.detail-heading{display:flex;min-width:0;align-items:flex-start;gap:16px}.booking-page-toolbar{min-width:0;flex:1}.section-title p,.history-panel p{margin:5px 0 0;color:var(--cc-muted);font-size:13px}.heading-status{display:flex;align-items:center;gap:8px}.surface-panel{padding:20px}.surface-panel h2{margin:0;font-size:16px}.section-title{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;margin-bottom:18px}.actions{display:flex;flex-wrap:wrap;gap:8px}.form-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0 16px}.form-grid .wide{grid-column:1/-1}.party-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:16px}.subsection{display:grid;gap:10px;padding:14px;border:1px solid var(--cc-border);border-radius:6px}.subsection h3{margin:0;font-size:14px}.container-row{display:grid;grid-template-columns:1fr 1fr 160px auto;gap:10px;margin-top:10px}.history-panel strong{font-size:14px}@media(max-width:900px){.form-grid,.party-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.container-row{grid-template-columns:1fr 1fr}}@media(max-width:600px){.detail-heading{flex-direction:column}.booking-page-toolbar{width:100%}.form-grid,.party-grid,.container-row{grid-template-columns:1fr}.form-grid .wide{grid-column:auto}.section-title{flex-direction:column}.actions{width:100%}}
|
||||
.booking-detail{min-width:0}.detail-heading{display:flex;min-width:0;align-items:flex-start;gap:16px}.booking-page-toolbar{min-width:0;flex:1}.section-title p,.history-panel p{margin:5px 0 0;color:var(--cc-muted);font-size:13px}.heading-status{display:flex;align-items:center;gap:8px}.surface-panel{padding:20px}.surface-panel h2{margin:0;font-size:16px}.section-title{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;margin-bottom:18px}.actions{display:flex;flex-wrap:wrap;gap:8px}.booking-groups{display:grid;grid-template-columns:minmax(0,1.4fr) repeat(2,minmax(0,1fr));gap:16px}.booking-group{min-width:0;overflow:hidden;border:1px solid #e8edf3;border-radius:8px;background:#fff}.booking-group>h3{margin:0;padding:14px 16px;border-bottom:1px solid #eef2f6;color:#1f2937;font-size:14px}.booking-group__body{padding:16px}.party-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:16px}.subsection{display:grid;gap:10px;padding:14px;border:1px solid var(--cc-border);border-radius:6px}.subsection h3{margin:0;font-size:14px}.container-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) 110px auto;gap:8px;margin-bottom:10px}.add-container{width:100%;margin-bottom:18px}.history-panel strong{font-size:14px}@media(max-width:1199px){.booking-groups{grid-template-columns:repeat(2,minmax(0,1fr))}.booking-group:last-child{grid-column:1/-1}}@media(max-width:720px){.booking-groups,.party-grid{grid-template-columns:1fr}.booking-group:last-child{grid-column:auto}.container-row{grid-template-columns:1fr 1fr}.detail-heading{flex-direction:column}.booking-page-toolbar{width:100%}.section-title{flex-direction:column}.actions{width:100%}}@media(max-width:420px){.surface-panel{padding:12px}.booking-group__body{padding:14px}.container-row{grid-template-columns:1fr}.actions :deep(.el-button){min-height:44px}}
|
||||
</style>
|
||||
|
||||
@@ -2,3 +2,23 @@ import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
describe('OceanBookingsView', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8'); it('keeps the operation column first and left fixed', () => { expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf("prop: 'bookingNo'")); expect(source).toContain("fixed: 'left'") }); it('renders loading, empty and error through shared components', () => { expect(source).toContain('LoadErrorState'); expect(source).toContain(':loading="loading"'); expect(source).toContain(':total="total"') }); it('keeps list actions in the table toolbar without a duplicate page title', () => { expect(source).toContain('<template #toolbar-actions>'); expect(source).not.toContain('<template #toolbar-start>'); expect(source).not.toContain('PageToolbar'); expect(source).not.toContain('<template #actions><el-button') }); it('shows the two frequent row actions directly without a more menu', () => { expect(source).toContain('<Eye :size="15" />查看'); expect(source).toContain('<Pencil :size="15" />编辑'); expect(source).not.toContain('MoreHorizontal') }); it('renders backend booking statuses through the domain translation contract', () => { expect(source).toContain('options: oceanBookingStatusOptions'); expect(source).toContain(':status="oceanBookingStatusLabel(row.status)"'); expect(source).not.toContain(':status="row.status"') }) })
|
||||
|
||||
describe('OceanBookingsView prototype field matrix', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8')
|
||||
|
||||
it('keeps the frozen business field sequence after the first action column', () => {
|
||||
const fields = ['bookingNo', 'customerName', 'carrierName', 'bookingAgent', 'vesselName', 'pol', 'pod', 'etd', 'eta', 'status', 'updatedAt']
|
||||
let previous = source.indexOf("key: 'actions'")
|
||||
for (const field of fields) {
|
||||
const current = source.indexOf(`prop: '${field}'`, previous + 1)
|
||||
expect(current, field).toBeGreaterThan(previous)
|
||||
previous = current
|
||||
}
|
||||
})
|
||||
|
||||
it('formats schedule and update timestamps without adding print actions', () => {
|
||||
expect(source).toContain('<template #updatedAt="{ row }">{{ formatDateTime(row.updatedAt) }}</template>')
|
||||
expect(source).toContain('<template #etd="{ row }">{{ formatDateTime(row.etd) }}</template>')
|
||||
expect(source).not.toContain('打印')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,12 +30,19 @@ const fields: QueryField[] = [
|
||||
]
|
||||
const columns = [
|
||||
{ key: 'actions', label: '操作', fixed: 'left', width: 170, slotName: 'actions', hideable: false, movable: false },
|
||||
{ prop: 'bookingNo', label: '订舱编号', minWidth: 150 }, { prop: 'customerName', label: '客户', minWidth: 160 },
|
||||
{ prop: 'billOfLadingNo', label: '提单号', minWidth: 140 }, { prop: 'pol', label: '起运港', minWidth: 125 }, { prop: 'pod', label: '目的港', minWidth: 125 },
|
||||
{ prop: 'carrierName', label: '船公司', minWidth: 130 }, { prop: 'bookingAgent', label: '订舱代理', minWidth: 140 },
|
||||
{ prop: 'vesselName', label: '船名/航次', minWidth: 150, slotName: 'vessel' }, { prop: 'etd', label: '预计开船', minWidth: 140 },
|
||||
{ prop: 'eta', label: '预计到港', minWidth: 140 }, { prop: 'status', label: '状态', minWidth: 110, slotName: 'status' },
|
||||
{ prop: 'bookingNo', label: '订舱号', minWidth: 150 },
|
||||
{ prop: 'customerName', label: '客户', minWidth: 160 },
|
||||
{ prop: 'carrierName', label: '船公司', minWidth: 130 },
|
||||
{ prop: 'bookingAgent', label: '订舱代理', minWidth: 140 },
|
||||
{ prop: 'vesselName', label: '船名 / 航次', minWidth: 175, slotName: 'vessel' },
|
||||
{ prop: 'pol', label: '起运港', minWidth: 125 },
|
||||
{ prop: 'pod', label: '目的港', minWidth: 125 },
|
||||
{ prop: 'etd', label: '预计开船', minWidth: 155, slotName: 'etd' },
|
||||
{ prop: 'eta', label: '预计到港', minWidth: 155, slotName: 'eta' },
|
||||
{ prop: 'status', label: '状态', minWidth: 110, slotName: 'status' },
|
||||
{ prop: 'updatedAt', label: '更新时间', minWidth: 155, slotName: 'updatedAt' },
|
||||
]
|
||||
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||||
async function load() { loading.value = true; error.value = null; try { const result = await listOceanBookings({ page: page.value, size: pageSize.value, keyword: String(query.keyword || '') || undefined, status: (query.status || undefined) as OceanBookingStatus | undefined } satisfies OceanBookingQuery); rows.value = result.records; total.value = result.total } catch (e) { error.value = getApiErrorDetails(e) } finally { loading.value = false } }
|
||||
function open(id?: string) { void router.push(id ? `/ocean-bookings/${id}` : '/ocean-bookings/new') }
|
||||
function search() { if (page.value === 1) void load(); else page.value = 1 }
|
||||
@@ -51,7 +58,10 @@ watch([page, pageSize], () => void load()); onMounted(load)
|
||||
<template #toolbar-actions><el-button v-if="access.can('platform:firstleg:ocean-booking:create')" type="primary" @click="open()"><Plus :size="16" />新增订舱</el-button></template>
|
||||
<template #actions="{ row }"><el-button v-if="access.can('platform:firstleg:ocean-booking:view')" link type="primary" @click="open(row.id)"><Eye :size="15" />查看</el-button><el-button v-if="access.can('platform:firstleg:ocean-booking:edit')" link @click="open(row.id)"><Pencil :size="15" />编辑</el-button></template>
|
||||
<template #vessel="{ row }">{{ row.vesselName || '—' }}<span v-if="row.voyageNo"> / {{ row.voyageNo }}</span></template>
|
||||
<template #etd="{ row }">{{ formatDateTime(row.etd) }}</template>
|
||||
<template #eta="{ row }">{{ formatDateTime(row.eta) }}</template>
|
||||
<template #status="{ row }"><StatusBadge :status="oceanBookingStatusLabel(row.status)" /></template>
|
||||
<template #updatedAt="{ row }">{{ formatDateTime(row.updatedAt) }}</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('InboundFeeDetailManager', () => {
|
||||
})
|
||||
|
||||
it('keeps currency fixed to CNY in the form and write payload', async () => {
|
||||
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit'])
|
||||
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'])
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('新增费用'))?.trigger('click')
|
||||
await flushPromises()
|
||||
@@ -105,4 +105,40 @@ describe('InboundFeeDetailManager', () => {
|
||||
expect(wrapper.find('.fee-table-shell').exists()).toBe(true)
|
||||
expect(wrapper.find('.detail-table-wrap').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('loads RECEIVABLE fee items without requesting counterparties', async () => {
|
||||
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'])
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('新增费用'))?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(listInboundFeeItemOptions).toHaveBeenCalledTimes(1)
|
||||
expect(listInboundFeePartnerOptions).not.toHaveBeenCalled()
|
||||
expect(document.body.textContent).toContain('缺少往来单位查看权限')
|
||||
expect(document.body.textContent).toContain('应收费用不受影响')
|
||||
})
|
||||
|
||||
it('does not expose the editor without fee-item view permission', async () => {
|
||||
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit'])
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('button').some((button) => button.text().includes('新增费用'))).toBe(false)
|
||||
expect(listInboundFeeItemOptions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps fee items usable when the independent counterparty request fails', async () => {
|
||||
vi.mocked(listInboundFeeDetails).mockResolvedValue([{ ...pending, direction: 'PAYABLE', partnerId: '41', partnerName: '测试往来单位' }])
|
||||
vi.mocked(listInboundFeePartnerOptions).mockRejectedValue(new ApiResponseError('往来单位不可用', 'A0403', 403, null, 'request-partner', 'trace-partner'))
|
||||
const wrapper = mountManager([
|
||||
'firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit',
|
||||
'firstleg:fee-item:view', 'platform:counterparty:list',
|
||||
])
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('编辑'))?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(listInboundFeeItemOptions).toHaveBeenCalledTimes(1)
|
||||
expect(listInboundFeePartnerOptions).toHaveBeenCalledTimes(1)
|
||||
expect(document.body.textContent).toContain('往来单位不可用')
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,6 +24,11 @@ const access = useAccessPreviewStore()
|
||||
const rows = ref<InboundFeeDetail[]>([])
|
||||
const feeItems = ref<InboundFeeItemOption[]>([])
|
||||
const partners = ref<InboundFeePartnerOption[]>([])
|
||||
const feeItemsLoading = ref(false)
|
||||
const partnersLoading = ref(false)
|
||||
const feeItemsError = ref<ApiErrorDetails | null>(null)
|
||||
const partnersError = ref<ApiErrorDetails | null>(null)
|
||||
const partnersLoadAttempted = ref(false)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
@@ -49,6 +54,12 @@ const rules: FormRules = {
|
||||
currency: [{ required: true, pattern: /^[A-Z]{3}$/, message: '货币须为三个大写字母', trigger: 'blur' }],
|
||||
}
|
||||
const canView = computed(() => access.can('firstleg:inbound:fee-detail:view'))
|
||||
const canViewFeeItems = computed(() => access.can('firstleg:fee-item:view'))
|
||||
const canViewPartners = computed(() => access.can('platform:counterparty:list'))
|
||||
const directionOptions = computed(() => [
|
||||
{ label: '应收', value: 'RECEIVABLE' },
|
||||
{ label: '应付', value: 'PAYABLE', disabled: !canViewPartners.value },
|
||||
])
|
||||
const applicableItems = computed(() => feeItems.value.filter((item) => form.direction === 'RECEIVABLE' ? item.warehouseReceivable : item.warehousePayable))
|
||||
|
||||
function canEdit(row: InboundFeeDetail) { return access.can('firstleg:inbound:fee-detail:edit') && row.reviewStatus !== 'APPROVED' }
|
||||
@@ -67,9 +78,21 @@ async function load() {
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
try { [feeItems.value, partners.value] = await Promise.all([listInboundFeeItemOptions(), listInboundFeePartnerOptions()]) }
|
||||
catch (error) { actionError.value = getApiErrorDetails(error, '费用维护选项加载失败') }
|
||||
async function loadFeeItems() {
|
||||
if (!canViewFeeItems.value || feeItemsLoading.value) return
|
||||
feeItemsLoading.value = true; feeItemsError.value = null
|
||||
try { feeItems.value = await listInboundFeeItemOptions() }
|
||||
catch (error) { feeItemsError.value = getApiErrorDetails(error, '费用项目加载失败') }
|
||||
finally { feeItemsLoading.value = false }
|
||||
}
|
||||
|
||||
async function loadPartners() {
|
||||
if (!canViewPartners.value || partnersLoading.value || partners.value.length || partnersLoadAttempted.value) return
|
||||
partnersLoadAttempted.value = true
|
||||
partnersLoading.value = true; partnersError.value = null
|
||||
try { partners.value = await listInboundFeePartnerOptions() }
|
||||
catch (error) { partnersError.value = getApiErrorDetails(error, '往来单位加载失败') }
|
||||
finally { partnersLoading.value = false }
|
||||
}
|
||||
|
||||
function assignForm(row?: InboundFeeDetail) {
|
||||
@@ -82,9 +105,12 @@ function assignForm(row?: InboundFeeDetail) {
|
||||
}
|
||||
|
||||
async function openEditor(mode: 'create' | 'edit' | 'adjust', row?: InboundFeeDetail) {
|
||||
partnersLoadAttempted.value = false
|
||||
editorMode.value = mode; target.value = row || null; assignForm(row); actionError.value = null
|
||||
feeItemsError.value = null; partnersError.value = null
|
||||
editorOpen.value = true
|
||||
if (!feeItems.value.length) await loadOptions()
|
||||
if (!feeItems.value.length) await loadFeeItems()
|
||||
if (form.direction === 'PAYABLE') await loadPartners()
|
||||
}
|
||||
|
||||
function normalizePayload(): SaveInboundFeeDetailRequest {
|
||||
@@ -100,6 +126,8 @@ async function finishAction(message: string) {
|
||||
|
||||
async function save() {
|
||||
if (!await formRef.value?.validate()) return
|
||||
if (!canViewFeeItems.value) { actionError.value = { message: '当前账号缺少费用项目查看权限,无法保存费用' }; return }
|
||||
if (form.direction === 'PAYABLE' && !canViewPartners.value) { actionError.value = { message: '当前账号缺少往来单位查看权限,无法维护应付费用' }; return }
|
||||
if (form.direction === 'PAYABLE' && !form.partnerId) { actionError.value = { message: '应付费用必须选择往来单位' }; return }
|
||||
saving.value = true; actionError.value = null
|
||||
try {
|
||||
@@ -142,7 +170,10 @@ async function voidRow(row: InboundFeeDetail) {
|
||||
}
|
||||
|
||||
watch(() => [props.accountId, props.inboundId], () => void load())
|
||||
watch(() => form.direction, () => { if (form.direction === 'RECEIVABLE') { form.partnerId = null; form.partnerName = null } })
|
||||
watch(() => form.direction, (direction) => {
|
||||
if (direction === 'RECEIVABLE') { form.partnerId = null; form.partnerName = null; partnersError.value = null }
|
||||
else void loadPartners()
|
||||
})
|
||||
onMounted(() => void load())
|
||||
</script>
|
||||
|
||||
@@ -152,7 +183,7 @@ onMounted(() => void load())
|
||||
<span>共 {{ rows.length }} 条应收应付费用明细</span>
|
||||
<div>
|
||||
<el-tooltip content="刷新费用明细" placement="top"><el-button circle aria-label="刷新费用明细" :loading="loading" @click="load"><RefreshCw :size="16" /></el-button></el-tooltip>
|
||||
<el-button v-if="access.can('firstleg:inbound:fee-detail:edit')" type="primary" @click="openEditor('create')"><Plus :size="16" />新增费用</el-button>
|
||||
<el-button v-if="access.can('firstleg:inbound:fee-detail:edit') && canViewFeeItems" type="primary" @click="openEditor('create')"><Plus :size="16" />新增费用</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-alert v-if="!canView" title="当前账号没有查看费用明细的权限" type="warning" :closable="false" show-icon />
|
||||
@@ -195,11 +226,15 @@ onMounted(() => void load())
|
||||
|
||||
<AppDialog v-model="editorOpen" :title="editorMode === 'create' ? '新增费用' : editorMode === 'adjust' ? '调整已审核费用' : '编辑费用'" width="min(680px, calc(100vw - 28px))" :confirm-loading="saving" confirm-text="保存" @confirm="save">
|
||||
<el-alert v-if="editorMode === 'adjust'" title="调整将生成新版本并重新进入待审核状态" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-if="!canViewFeeItems" title="当前账号缺少费用项目查看权限,无法新增或修改费用" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-if="!canViewPartners" title="当前账号缺少往来单位查看权限,应付费用不可维护;应收费用不受影响" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-if="feeItemsError" type="error" :closable="true" @close="feeItemsError = null"><template #title>{{ feeItemsError.message }}</template><ApiErrorMeta :details="feeItemsError" /></el-alert>
|
||||
<el-alert v-if="partnersError" type="error" :closable="true" @close="partnersError = null"><template #title>{{ partnersError.message }}</template><ApiErrorMeta :details="partnersError" /></el-alert>
|
||||
<el-alert v-if="actionError" type="error" :closable="true" @close="actionError = null"><template #title>{{ actionError.message }}</template><ApiErrorMeta :details="actionError" /></el-alert>
|
||||
<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><el-option v-for="item in applicableItems" :key="item.id" :label="item.feeTypeName" :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><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="direction"><el-segmented v-model="form.direction" :options="directionOptions" /></el-form-item>
|
||||
<el-form-item label="费用项目" prop="feeItemId"><el-select v-model="form.feeItemId" filterable :loading="feeItemsLoading" :disabled="!canViewFeeItems"><el-option v-for="item in applicableItems" :key="item.id" :label="item.feeTypeName" :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"><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="货币" prop="currency"><el-input model-value="CNY" readonly aria-label="货币 CNY" /></el-form-item>
|
||||
<el-form-item label="单价" prop="unitPrice"><el-input-number v-model="form.unitPrice" :min="0" :precision="4" /></el-form-item>
|
||||
|
||||
@@ -209,7 +209,7 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(source).toContain('子单 · 第 ${row.relation.splitSequence} 次')
|
||||
expect(source).toContain('title="父子订单关联"')
|
||||
expect(source).toContain('@click="openOrderRelation(row as UnifiedOrderResource)"')
|
||||
expect(source).toContain("{ key: 'fulfillment', label: '拆分履约', icon: ListChecks }")
|
||||
expect(source).toContain("{ key: 'fulfillment', label: '拆分履约', icon: OrderFulfillmentIcon }")
|
||||
expect(source).toContain("activeDetailTab === 'fulfillment'")
|
||||
expect(source).toContain('根单、已完成分片与当前余量')
|
||||
})
|
||||
@@ -251,10 +251,17 @@ describe('UnifiedOrdersView contract', () => {
|
||||
|
||||
it('mounts downstream progress inside the existing order detail without opening unsafe writes', () => {
|
||||
expect(source).toContain('UnifiedOrderWorkflowPanel')
|
||||
expect(source).toContain("{ key: 'workflow', label: '后半程作业'")
|
||||
expect(source).toContain("{ key: 'workflow', label: '后半程作业', icon: OrderFulfillmentIcon }")
|
||||
expect(source).toContain(':order-id="String(detail.summary.orderId)"')
|
||||
expect(source).not.toContain(':show-delivery=')
|
||||
expect(source).toContain("detail.value?.summary.inboundId")
|
||||
expect(routeSource).not.toContain('unified-order-workflow')
|
||||
})
|
||||
|
||||
it('keeps detail tabs compact on one horizontally scrollable row', () => {
|
||||
expect(source).toContain('.detail-tabs { display: flex; overflow-x: auto; overflow-y: hidden;')
|
||||
expect(source).toContain('min-width: max-content; min-height: 56px; flex: 0 0 auto;')
|
||||
expect(source).not.toContain('grid-template-columns: repeat(6, minmax(124px, 1fr))')
|
||||
expect(source).not.toContain('.detail-tabs { grid-template-columns: repeat(6, 132px); }')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch, type Component } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Eye, GitBranch, History, ListChecks, MoreHorizontal, PackageOpen, Ruler, SlidersHorizontal, StickyNote, WalletCards } from '@lucide/vue'
|
||||
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Eye, GitBranch, History, MoreHorizontal, PackageOpen, Ruler, SlidersHorizontal, StickyNote, WalletCards } from '@lucide/vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
getPlatformUnifiedOrder,
|
||||
@@ -51,6 +51,15 @@ import {
|
||||
getInboundMeasurement,
|
||||
type InboundMeasurementDetail,
|
||||
} from '@/modules/inbounds'
|
||||
import {
|
||||
OrderBasicInfoIcon,
|
||||
OrderCargoInfoIcon,
|
||||
OrderFeeInfoIcon,
|
||||
OrderFulfillmentIcon,
|
||||
OrderHistoryIcon,
|
||||
OrderMeasurementIcon,
|
||||
OrderOtherIcon,
|
||||
} from '@/core/navigation/prototypeMenuIcons'
|
||||
|
||||
type FilterModel = QueryModel & {
|
||||
tenantId: string
|
||||
@@ -115,17 +124,17 @@ const detailTenantId = computed(() => typeof route.query.tenantId === 'string' ?
|
||||
const detailRouteKey = computed(() => orderId.value ? `${detailTenantId.value}:${orderId.value}` : '')
|
||||
|
||||
const detailTabs = [
|
||||
{ key: 'basic', label: '基本信息', icon: Box },
|
||||
{ key: 'cargo', label: '货品信息', icon: PackageOpen },
|
||||
{ key: 'fees', label: '费用信息', icon: WalletCards },
|
||||
{ key: 'history', label: '状态历史', icon: History },
|
||||
{ key: 'measurement', label: '测量数据', icon: Ruler },
|
||||
{ key: 'other', label: '其他', icon: StickyNote },
|
||||
{ key: 'fulfillment', label: '拆分履约', icon: ListChecks },
|
||||
{ key: 'basic', label: '基本信息', icon: OrderBasicInfoIcon },
|
||||
{ key: 'cargo', label: '货品信息', icon: OrderCargoInfoIcon },
|
||||
{ key: 'fees', label: '费用信息', icon: OrderFeeInfoIcon },
|
||||
{ key: 'history', label: '状态历史', icon: OrderHistoryIcon },
|
||||
{ key: 'measurement', label: '测量数据', icon: OrderMeasurementIcon },
|
||||
{ key: 'other', label: '其他', icon: OrderOtherIcon },
|
||||
{ key: 'fulfillment', label: '拆分履约', icon: OrderFulfillmentIcon },
|
||||
]
|
||||
|
||||
const detailTabOptions = computed(() => detail.value?.summary.inboundId
|
||||
? [...detailTabs, { key: 'workflow', label: '后半程作业', icon: ListChecks }]
|
||||
? [...detailTabs, { key: 'workflow', label: '后半程作业', icon: OrderFulfillmentIcon }]
|
||||
: detailTabs)
|
||||
|
||||
const customerMeasurementColumns = [
|
||||
@@ -1090,8 +1099,8 @@ onMounted(() => {
|
||||
.detail-overview article > span, .detail-overview article > small { display: block; overflow: hidden; color: var(--cc-muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.detail-overview strong { display: block; margin: 7px 0 5px; overflow: hidden; color: #172033; font-size: 20px; line-height: 1.2; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.detail-overview strong small { margin-left: 5px; color: #475467; font-size: 11px; }
|
||||
.detail-tabs { display: grid; grid-template-columns: repeat(6, minmax(124px, 1fr)); overflow-x: auto; border: 1px solid var(--cc-border); border-bottom: 0; border-radius: 8px 8px 0 0; background: #fff; }
|
||||
.detail-tabs button { position: relative; display: flex; min-height: 56px; align-items: center; justify-content: center; gap: 7px; padding: 0 14px; border: 0; border-right: 1px solid #edf0f4; background: transparent; color: #667085; font-weight: 650; cursor: pointer; }
|
||||
.detail-tabs { display: flex; overflow-x: auto; overflow-y: hidden; border: 1px solid var(--cc-border); border-bottom: 0; border-radius: 8px 8px 0 0; background: #fff; scrollbar-width: thin; }
|
||||
.detail-tabs button { position: relative; display: inline-flex; min-width: max-content; min-height: 56px; flex: 0 0 auto; align-items: center; justify-content: center; gap: 7px; padding: 0 22px; border: 0; border-right: 1px solid #edf0f4; background: transparent; color: #667085; font-weight: 650; cursor: pointer; }
|
||||
.detail-tabs button:last-child { border-right: 0; }
|
||||
.detail-tabs button::after { position: absolute; right: 14px; bottom: 0; left: 14px; height: 3px; background: transparent; content: ''; }
|
||||
.detail-tabs button:hover { background: #fafbff; color: #344054; }
|
||||
@@ -1140,6 +1149,6 @@ onMounted(() => {
|
||||
.notes-list li > p { margin: 0; color: var(--cc-text); font-size: 13px; }
|
||||
.notes-list span { display: block; margin-top: 5px; color: var(--cc-muted); font-size: 12px; }
|
||||
@media (max-width: 960px) { .detail-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); } .detail-overview article:nth-child(2) { border-right: 0; } .detail-overview article:nth-child(-n+2) { border-bottom: 1px solid var(--cc-border); } .order-info-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .measurement-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 720px) { .page-intro { align-items: stretch; flex-direction: column; } .page-intro :deep(.el-button) { width: 100%; min-height: 44px; } .status-tab { min-height: 46px; padding: 0 14px; } .table-actions :deep(.el-button) { width: 100%; min-height: 40px; } .row-actions { flex-wrap: wrap; } .order-detail-header { align-items: stretch; flex-direction: column; } .order-detail-heading { display: grid; grid-template-columns: auto 36px minmax(0, 1fr) auto; } .detail-header-actions { display: grid; grid-template-columns: 1fr; } .detail-header-actions :deep(.el-button) { min-height: 40px; } .detail-overview, .order-info-grid, .measurement-grid { grid-template-columns: 1fr; } .detail-overview article { border-right: 0; border-bottom: 1px solid var(--cc-border); } .detail-overview article:last-child { border-bottom: 0; } .order-info-grid > div.wide { grid-column: auto; } .detail-tabs { grid-template-columns: repeat(6, 132px); } .detail-panel { padding: 16px; } .measurement-section-title { align-items: flex-start; flex-wrap: wrap; padding: 12px; } .measurement-section-summary { width: 100%; flex-basis: calc(100% - 34px); flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; white-space: normal; } .measurement-section-title > svg { margin-left: auto; } .measurement-total { flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; } .measurement-total strong { width: 100%; } }
|
||||
@media (max-width: 720px) { .page-intro { align-items: stretch; flex-direction: column; } .page-intro :deep(.el-button) { width: 100%; min-height: 44px; } .status-tab { min-height: 46px; padding: 0 14px; } .table-actions :deep(.el-button) { width: 100%; min-height: 40px; } .row-actions { flex-wrap: wrap; } .order-detail-header { align-items: stretch; flex-direction: column; } .order-detail-heading { display: grid; grid-template-columns: auto 36px minmax(0, 1fr) auto; } .detail-header-actions { display: grid; grid-template-columns: 1fr; } .detail-header-actions :deep(.el-button) { min-height: 40px; } .detail-overview, .order-info-grid, .measurement-grid { grid-template-columns: 1fr; } .detail-overview article { border-right: 0; border-bottom: 1px solid var(--cc-border); } .detail-overview article:last-child { border-bottom: 0; } .order-info-grid > div.wide { grid-column: auto; } .detail-tabs button { padding: 0 18px; } .detail-panel { padding: 16px; } .measurement-section-title { align-items: flex-start; flex-wrap: wrap; padding: 12px; } .measurement-section-summary { width: 100%; flex-basis: calc(100% - 34px); flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; white-space: normal; } .measurement-section-title > svg { margin-left: auto; } .measurement-total { flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; } .measurement-total strong { width: 100%; } }
|
||||
@media (max-width: 420px) { .order-detail-heading { grid-template-columns: auto minmax(0, 1fr) auto; } .order-mark { display: none; } }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user