style(admin): unify dialog layouts and dense readonly presentation
This commit is contained in:
@@ -246,14 +246,14 @@ describe('CarrierScheduleQueryPanel loading states', () => {
|
||||
expect(wrapper.findAll('button').some((button) => button.text().includes('人工补录节点'))).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the form context and dialog-scoped idempotency key when a conflict is retried', async () => {
|
||||
it.each([false, true])('keeps form inputs and the dialog-scoped retry key with dense=%s', async (dense) => {
|
||||
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [{ ...queryView, query: { ...queryView.query, resultPortScheduleId: null }, expired: false }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
const currentDetail = { containerBookingId: '3', currentNode: null, currentNodeAt: null, nodes: [] }
|
||||
vi.mocked(getCarrierScheduleQuery).mockResolvedValue({ query: { ...queryView, query: { ...queryView.query, resultPortScheduleId: null } }, nodes: [], exceptions: [], histories: [] })
|
||||
vi.mocked(appendContainerShippingNode)
|
||||
.mockRejectedValueOnce(new ApiResponseError('版本冲突', 'A0409', 409, null, 'req-409', 'trace-409'))
|
||||
.mockResolvedValueOnce(currentDetail)
|
||||
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2', containerBookingId: '3' }, global: { plugins: [ElementPlus, pinia] } })
|
||||
const wrapper = mount(CarrierScheduleQueryPanel, { props: { dense, cabinetNo: 'CAB-3', containerNo: 'CONT-3', oceanBookingId: '2', containerBookingId: '3' }, global: { plugins: [ElementPlus, pinia] } })
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('人工补录节点'))!.trigger('click')
|
||||
@@ -266,12 +266,21 @@ describe('CarrierScheduleQueryPanel loading states', () => {
|
||||
await wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('补录未成功,已保留当前填写内容')
|
||||
expect(wrapper.text()).toContain('req-409'); expect(wrapper.text()).toContain('trace-409')
|
||||
const dialog = wrapper.findComponent({ name: 'AppDialog' })
|
||||
expect(dialog.props('title')).toBe('人工补录船节点 · CAB-3')
|
||||
if (dense) {
|
||||
await dialog.get('[aria-label="展开全屏"]').trigger('click')
|
||||
expect(dialog.find('.el-dialog.is-fullscreen').exists()).toBe(true)
|
||||
expect(dialog.find('textarea').element.value).toBe('船司官网人工核验')
|
||||
}
|
||||
await wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
|
||||
const first = vi.mocked(appendContainerShippingNode).mock.calls[0]!
|
||||
const second = vi.mocked(appendContainerShippingNode).mock.calls[1]!
|
||||
expect(first[1]).toBeTruthy(); expect(second[1]).toBe(first[1])
|
||||
expect(second[2]).toEqual(first[2])
|
||||
expect(second[2]).not.toHaveProperty('cabinetNo')
|
||||
wrapper.unmount()
|
||||
expect(second[2]).toMatchObject({ standardNode: 'IN_TRANSIT', estimatedAt: '2026-08-22T10:00:00', actualAt: null, locationCode: 'PACIFIC', locationName: 'Pacific Ocean', reason: '船司官网人工核验', observedAt: expect.any(String) })
|
||||
})
|
||||
|
||||
|
||||
@@ -20,7 +20,11 @@ type WarehouseTimelineEntry = {
|
||||
operatorName?: string | null; occurredAt?: string | null
|
||||
}
|
||||
type FulfillmentEvent = { key: string; time: string; type: '仓内操作' | '确认离仓' | '船运节点' | '人工补录' | '异常'; title: string; summary: string; source: string; actual: boolean }
|
||||
const props = defineProps<{ dense?: boolean; oceanBookingId?: string | null; containerBookingId?: string | null; portScheduleId?: string | null; warehouseTimeline?: WarehouseTimelineEntry[] }>()
|
||||
const props = defineProps<{ dense?: boolean; cabinetNo?: string | null; containerNo?: string | null; oceanBookingId?: string | null; containerBookingId?: string | null; portScheduleId?: string | null; warehouseTimeline?: WarehouseTimelineEntry[] }>()
|
||||
const manualDialogTitle = computed(() => {
|
||||
const code = props.cabinetNo?.trim() || props.containerNo?.trim()
|
||||
return code ? `人工补录船节点 · ${code}` : '人工补录船节点'
|
||||
})
|
||||
const access = useAccessPreviewStore()
|
||||
const loading = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
@@ -255,7 +259,7 @@ watch(manualDialogOpen, (open) => { if (!open && !manualSaving.value) { manualId
|
||||
|
||||
<template>
|
||||
<section class="carrier-query" :class="{ 'admin-density': dense }" aria-label="货柜履约轨迹">
|
||||
<header><div><h2>货柜履约轨迹</h2><p>统一展示仓内操作、确认离仓与船运节点;预计节点不会推进实际进度。</p></div><div class="header-actions"><span class="history-count">仓内 {{ filteredWarehouseTimeline.length }} 条 · 共 {{ fulfillmentEvents.length }} 条</span><el-button v-if="props.containerBookingId && access.can('platform:firstleg:port-schedule-query:manual')" :class="{ 'density-action': dense }" data-tone="primary" size="small" type="primary" plain @click="openManualDialog"><Plus v-if="!dense" :size="15" />人工补录节点</el-button><el-tag v-if="query" :type="statusType" effect="light">{{ statusText }}</el-tag></div></header>
|
||||
<header><div><h2>货柜履约轨迹</h2></div><div class="header-actions"><span class="history-count">仓内 {{ filteredWarehouseTimeline.length }} 条 · 共 {{ fulfillmentEvents.length }} 条</span><el-button v-if="props.containerBookingId && access.can('platform:firstleg:port-schedule-query:manual')" :class="{ 'density-action': dense }" data-tone="primary" size="small" type="primary" plain @click="openManualDialog"><Plus v-if="!dense" :size="15" />人工补录节点</el-button><el-tag v-if="query" :type="statusType" effect="light">{{ statusText }}</el-tag></div></header>
|
||||
<LoadErrorState v-if="error" compact title="船节点查询状态加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-else-if="loading" class="loading-state"><el-skeleton :rows="1" animated /></div>
|
||||
<template v-else>
|
||||
@@ -270,11 +274,21 @@ watch(manualDialogOpen, (open) => { if (!open && !manualSaving.value) { manualId
|
||||
<details v-if="supersededActualNodes.length" class="technical-details duplicate-node-history"><summary>历史重复实际节点({{ supersededActualNodes.length }})</summary><ol class="technical-history"><li v-for="item in supersededActualNodes" :key="item.id"><span>{{ nodeTimeText(item) }}</span><strong>{{ nodeText(item.standardNode) }}</strong><small>{{ nodeLocation(item) }} · {{ item.providerCode }}<template v-if="item.sourceReference"> · {{ item.sourceReference }}</template></small></li></ol></details>
|
||||
<details v-if="query" class="technical-details"><summary>查询任务摘要、异常与变更历史</summary><dl class="query-facts" :class="{ 'density-fields': dense }"><div><dt>查询任务</dt><dd>{{ query.queryNo || query.id }}</dd></div><div><dt>跟踪标识</dt><dd>{{ trackingTypeText(query.trackingType) }} · {{ query.trackingNo || '—' }}</dd></div><div><dt>任务状态</dt><dd>{{ statusText }}</dd></div><div><dt>建立来源</dt><dd>{{ triggerSourceText(query.triggerSource) }}</dd></div><div><dt>最新实际节点</dt><dd>{{ nodeText(query.latestActualNode) }}</dd></div><div><dt>实际发生时间</dt><dd>{{ formatTime(query.latestActualAt) }}</dd></div><div><dt>最新来源</dt><dd>{{ latestSource }}</dd></div><div><dt>来源参考</dt><dd class="break-value">{{ sourceReference }}</dd></div><div><dt>观测时间</dt><dd>{{ formatTime(latestObservation) }}</dd></div><div><dt>最近成功</dt><dd>{{ formatTime(query.lastSuccessAt) }}</dd></div><div><dt>结果有效性</dt><dd>{{ queryView?.expired ? '结果已过期' : '结果有效' }}</dd></div><div v-if="query.lastErrorCode"><dt>最近错误码</dt><dd>{{ query.lastErrorCode }}</dd></div></dl><h3>任务变更历史</h3><p v-if="!histories.length" class="empty-line">暂无变更历史</p><ol v-else class="technical-history"><li v-for="item in histories" :key="item.id"><span>{{ formatTime(item.createdAt) }}</span><strong>{{ historyText(item.action) }}</strong><small>{{ item.operatorName || '系统' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></details>
|
||||
</template>
|
||||
<AppDialog :class="{ 'admin-density-dialog': dense }" v-model="manualDialogOpen" title="人工补录船节点" width="min(620px, calc(100vw - 28px))" confirm-text="确认补录" :confirm-loading="manualSaving" @confirm="submitManualNode">
|
||||
<AppDialog :class="{ 'admin-density-dialog': dense }" v-model="manualDialogOpen" :title="manualDialogTitle" :width="dense ? 'min(780px, calc(100vw - 28px))' : 'min(620px, calc(100vw - 28px))'" :fullscreenable="dense" confirm-text="确认补录" :confirm-loading="manualSaving" @confirm="submitManualNode">
|
||||
<el-alert v-if="manualError" type="error" :closable="false" show-icon title="补录未成功,已保留当前填写内容"><template #default><ApiErrorMeta :details="manualError" /></template></el-alert>
|
||||
<el-alert v-if="manualValidationError" type="warning" :closable="false" show-icon :title="manualValidationError" />
|
||||
<el-alert type="info" :closable="false" title="观测时间按提交时刻记录;预计事实不推进实际进度,既有事实不可编辑或删除。" />
|
||||
<el-form label-position="top" class="manual-form">
|
||||
<section v-if="dense" class="density-dialog-section" data-tone="work">
|
||||
<el-form class="density-form">
|
||||
<table class="density-table density-table--editable manual-editor-table" aria-label="人工补录船节点"><tbody>
|
||||
<tr><td colspan="2"><el-form-item label-position="left" label-width="90px" label="标准节点" required><el-select v-model="manualForm.standardNode" placeholder="请选择标准节点"><el-option v-for="node in standardNodes" :key="node" :label="nodeText(node)" :value="node" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label-position="left" label-width="90px" label="预计时间"><el-date-picker v-model="manualForm.estimatedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="可选" /></el-form-item></td><td><el-form-item label-position="left" label-width="90px" label="实际时间"><el-date-picker v-model="manualForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="可选,填写后推进实际节点" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label-position="left" label-width="90px" label="地点编码"><el-input v-model="manualForm.locationCode" maxlength="32" placeholder="可选,如 USLAX" /></el-form-item></td><td><el-form-item label-position="left" label-width="90px" label="地点名称"><el-input v-model="manualForm.locationName" maxlength="128" placeholder="可选" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label-position="left" label-width="90px" label="补录原因" required><el-input v-model="manualForm.reason" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入事实来源与补录原因" /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</el-form>
|
||||
</section>
|
||||
<el-form v-else label-position="top" class="manual-form">
|
||||
<el-form-item label="标准节点" required><el-select v-model="manualForm.standardNode" placeholder="请选择标准节点" style="width:100%"><el-option v-for="node in standardNodes" :key="node" :label="nodeText(node)" :value="node" /></el-select></el-form-item>
|
||||
<div class="manual-form-grid"><el-form-item label="预计时间"><el-date-picker v-model="manualForm.estimatedAt" 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="manualForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="可选,填写后推进实际节点" style="width:100%" /></el-form-item></div>
|
||||
<div class="manual-form-grid"><el-form-item label="地点编码"><el-input v-model="manualForm.locationCode" maxlength="32" placeholder="可选,如 USLAX" /></el-form-item><el-form-item label="地点名称"><el-input v-model="manualForm.locationName" maxlength="128" placeholder="可选" /></el-form-item></div>
|
||||
@@ -286,5 +300,5 @@ watch(manualDialogOpen, (open) => { if (!open && !manualSaving.value) { manualId
|
||||
|
||||
<style scoped>
|
||||
.history-count{color:var(--cc-muted);font-size:var(--admin-font-caption, 12px);white-space:nowrap}
|
||||
.carrier-query{display:grid;gap:9px;padding-top:2px}.carrier-query>header{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--admin-space-5, 12px)}.header-actions{display:flex;align-items:center;gap:var(--admin-space-3, 8px)}.carrier-query h2{margin:0;font-size:var(--admin-font-section, 15px)}.carrier-query header p{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption, 12px)}.loading-state{padding:var(--admin-space-2, 6px) 0}.latest-result{display:flex;flex-wrap:wrap;align-items:center;gap:var(--admin-space-2, 6px) var(--admin-space-6, 16px);padding:7px var(--admin-space-4, 10px);border-left:3px solid var(--el-color-primary);background:var(--el-color-primary-light-9);font-size:var(--admin-font-caption, 12px)}.fulfillment-timeline{display:grid;margin:0;padding:0;list-style:none}.fulfillment-timeline li{display:grid;grid-template-columns:130px 86px 150px minmax(180px,1fr) minmax(120px,.8fr);align-items:center;gap:7px;padding:var(--admin-space-2, 6px) 2px;border-top:1px solid var(--cc-border);font-size:var(--admin-font-caption, 12px)}.fulfillment-timeline time,.fulfillment-timeline span,.fulfillment-timeline small{overflow:hidden;color:var(--cc-muted);text-overflow:ellipsis;white-space:nowrap}.tracking-prompt{margin:0;padding:7px var(--admin-space-4, 10px);border-left:3px solid var(--el-color-info);background:var(--cc-bg-subtle,#f7f8fa);color:var(--cc-muted);font-size:var(--admin-font-caption, 12px)}.empty-line{margin:0;color:var(--cc-muted);font-size:var(--admin-font-caption, 12px)}.technical-details{border-top:1px solid var(--cc-border);font-size:var(--admin-font-caption, 12px)}.technical-details summary{padding:7px 2px;color:var(--cc-primary);cursor:pointer}.query-facts:not(.density-fields){display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--admin-space-3, 8px) var(--admin-space-6, 16px);margin:4px 0 var(--admin-space-4, 10px)}.query-facts:not(.density-fields)>div{min-width:0}.query-facts:not(.density-fields) dt{color:var(--cc-muted);font-size:var(--admin-font-caption, 11px)}.query-facts:not(.density-fields) dd{margin:2px 0 0;font-size:var(--admin-font-caption, 12px)}.break-value{overflow-wrap:anywhere}.technical-details h3{margin:var(--admin-space-3, 8px) 0 5px;font-size:var(--admin-font-caption, 12px)}.technical-history{margin:0;padding:0;list-style:none}.technical-history li{display:grid;grid-template-columns:130px 150px minmax(0,1fr);gap:var(--admin-space-3, 8px);padding:5px 0;border-top:1px solid var(--cc-border)}.technical-history small{color:var(--cc-muted)}.manual-form{display:grid;gap:2px;margin-top:var(--admin-space-5, 12px)}.manual-form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-5, 12px)}@media(max-width:1200px){.query-facts{grid-template-columns:repeat(2,minmax(0,1fr))}.fulfillment-timeline li{grid-template-columns:120px 82px 140px minmax(0,1fr)}.fulfillment-timeline small{grid-column:4}}@media(max-width:720px){.carrier-query>header{align-items:flex-start;flex-direction:column}.query-facts,.manual-form-grid{grid-template-columns:1fr}.fulfillment-timeline li{grid-template-columns:90px minmax(0,1fr)}.fulfillment-timeline strong,.fulfillment-timeline span,.fulfillment-timeline small{grid-column:2}.technical-history li{grid-template-columns:1fr}}.carrier-query.admin-density .fulfillment-timeline :is(time,span,small){overflow:visible;white-space:normal;overflow-wrap:anywhere}.carrier-query.admin-density .technical-history small{overflow-wrap:anywhere}
|
||||
.carrier-query{display:grid;gap:9px;padding-top:2px}.carrier-query>header{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--admin-space-5, 12px)}.header-actions{display:flex;align-items:center;gap:var(--admin-space-3, 8px)}.carrier-query h2{margin:0;font-size:var(--admin-font-section, 15px)}.loading-state{padding:var(--admin-space-2, 6px) 0}.latest-result{display:flex;flex-wrap:wrap;align-items:center;gap:var(--admin-space-2, 6px) var(--admin-space-6, 16px);padding:7px var(--admin-space-4, 10px);border-left:3px solid var(--el-color-primary);background:var(--el-color-primary-light-9);font-size:var(--admin-font-caption, 12px)}.fulfillment-timeline{display:grid;margin:0;padding:0;list-style:none}.fulfillment-timeline li{display:grid;grid-template-columns:130px 86px 150px minmax(180px,1fr) minmax(120px,.8fr);align-items:center;gap:7px;padding:var(--admin-space-2, 6px) 2px;border-top:1px solid var(--cc-border);font-size:var(--admin-font-caption, 12px)}.fulfillment-timeline time,.fulfillment-timeline span,.fulfillment-timeline small{overflow:hidden;color:var(--cc-muted);text-overflow:ellipsis;white-space:nowrap}.tracking-prompt{margin:0;padding:7px var(--admin-space-4, 10px);border-left:3px solid var(--el-color-info);background:var(--cc-bg-subtle,#f7f8fa);color:var(--cc-muted);font-size:var(--admin-font-caption, 12px)}.empty-line{margin:0;color:var(--cc-muted);font-size:var(--admin-font-caption, 12px)}.technical-details{border-top:1px solid var(--cc-border);font-size:var(--admin-font-caption, 12px)}.technical-details summary{padding:7px 2px;color:var(--cc-primary);cursor:pointer}.query-facts:not(.density-fields){display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--admin-space-3, 8px) var(--admin-space-6, 16px);margin:4px 0 var(--admin-space-4, 10px)}.query-facts:not(.density-fields)>div{min-width:0}.query-facts:not(.density-fields) dt{color:var(--cc-muted);font-size:var(--admin-font-caption, 11px)}.query-facts:not(.density-fields) dd{margin:2px 0 0;font-size:var(--admin-font-caption, 12px)}.break-value{overflow-wrap:anywhere}.technical-details h3{margin:var(--admin-space-3, 8px) 0 5px;font-size:var(--admin-font-caption, 12px)}.technical-history{margin:0;padding:0;list-style:none}.technical-history li{display:grid;grid-template-columns:130px 150px minmax(0,1fr);gap:var(--admin-space-3, 8px);padding:5px 0;border-top:1px solid var(--cc-border)}.technical-history small{color:var(--cc-muted)}.manual-editor-table{table-layout:fixed}.manual-form{display:grid;gap:2px;margin-top:var(--admin-space-5, 12px)}.manual-form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-5, 12px)}@media(max-width:1200px){.query-facts{grid-template-columns:repeat(2,minmax(0,1fr))}.fulfillment-timeline li{grid-template-columns:120px 82px 140px minmax(0,1fr)}.fulfillment-timeline small{grid-column:4}}@media(max-width:720px){.carrier-query>header{align-items:flex-start;flex-direction:column}.query-facts,.manual-form-grid{grid-template-columns:1fr}.fulfillment-timeline li{grid-template-columns:90px minmax(0,1fr)}.fulfillment-timeline strong,.fulfillment-timeline span,.fulfillment-timeline small{grid-column:2}.technical-history li{grid-template-columns:1fr}}.carrier-query.admin-density .fulfillment-timeline :is(time,span,small){overflow:visible;white-space:normal;overflow-wrap:anywhere}.carrier-query.admin-density .technical-history small{overflow-wrap:anywhere}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
import Panel from './ContainerPortSchedulePanel.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { getPortSchedule, createPortSchedule, updatePortSchedule } from '../api/platformPortSchedules'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
const can = vi.hoisted(() => vi.fn(() => true))
|
||||
@@ -11,18 +12,25 @@ const container = { id: 'C1', customerId: '0', customerName: '平台订舱', oce
|
||||
const original = { id: 'S1', scheduleNo: 'PS1', version: 5, customerId: '0', oceanBookingId: 'B1', containerBookingId: 'C1', sourceType: 'CARRIER', sourceName: '官网', sourceRef: 'REF', vesselName: 'OLD', voyageNo: 'V1', departurePortCode: 'CNSHA', destinationPortCode: 'USLAX', etdAt: container.estimatedSailingAt, etaAt: container.estimatedPortArrivalAt, observedAt: '2026-10-01T00:00:00', expiresAt: '2026-10-10T00:00:00', portCutoffAt: '2026-10-03T00:00:00', remark: '原备注' }
|
||||
const result = () => ({ portSchedule: { schedule: { ...original }, expired: false }, histories: [] })
|
||||
interface VM { begin(): Promise<void>; save(): Promise<void>; form: Record<string, string>; error: string; open: boolean; schedule: ReturnType<typeof result> | null }
|
||||
function setup(value = container) {
|
||||
const wrapper = mount(Panel, { props: { container: value }, global: { plugins: [ElementPlus], stubs: { AppDialog: { props: ['modelValue'], template: '<div v-if="modelValue"><slot /></div>' } } } })
|
||||
function setup(value = container, realDialog = false) {
|
||||
const wrapper = mount(Panel, { props: { container: value }, global: { plugins: [ElementPlus], stubs: realDialog ? {} : { AppDialog: { props: ['modelValue'], template: '<div v-if="modelValue"><slot /></div>' } } } })
|
||||
return { wrapper, vm: wrapper.vm as unknown as VM }
|
||||
}
|
||||
describe('original container schedule', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); can.mockReturnValue(true); vi.mocked(getPortSchedule).mockResolvedValue(result()); vi.mocked(updatePortSchedule).mockResolvedValue(result()); vi.mocked(createPortSchedule).mockResolvedValue(result()) })
|
||||
it('loads only the server selected ID, preserves original fields, and keeps key/body after failure', async () => {
|
||||
const { wrapper, vm } = setup(); await vm.begin()
|
||||
const { wrapper, vm } = setup(container, true); await vm.begin()
|
||||
expect(getPortSchedule).toHaveBeenCalledExactlyOnceWith('S1')
|
||||
vm.form.vesselName = 'NEW'; vm.form.voyageNo = 'V2'; vm.form.reason = '原船满载改配'
|
||||
vi.mocked(updatePortSchedule).mockRejectedValueOnce(new Error('timeout'))
|
||||
await vm.save(); expect(vm.form.vesselName).toBe('NEW'); expect(vm.error).toContain('timeout')
|
||||
await flushPromises()
|
||||
const dialog = wrapper.getComponent(AppDialog)
|
||||
expect(dialog.props('title')).toBe('原柜船期与修改历史 · D1')
|
||||
expect(dialog.getComponent({ name: 'ElForm' }).props('disabled')).toBe(true)
|
||||
await dialog.get('[aria-label="展开全屏"]').trigger('click')
|
||||
expect(dialog.find('.el-dialog.is-fullscreen').exists()).toBe(true)
|
||||
expect(dialog.get<HTMLInputElement>('input[placeholder="请输入船名"]').element.value).toBe('NEW')
|
||||
await vm.save()
|
||||
expect(vi.mocked(updatePortSchedule).mock.calls[1]).toEqual(vi.mocked(updatePortSchedule).mock.calls[0])
|
||||
expect(updatePortSchedule).toHaveBeenLastCalledWith('S1', { expectedVersion: 5, reason: '原船满载改配', data: expect.objectContaining({ vesselName: 'NEW', voyageNo: 'V2', sourceType: 'CARRIER', sourceName: '官网', sourceRef: 'REF', observedAt: original.observedAt, expiresAt: original.expiresAt, portCutoffAt: original.portCutoffAt, remark: '原备注', containerBookingId: 'C1' }) }, expect.any(String))
|
||||
@@ -36,12 +44,23 @@ describe('original container schedule', () => {
|
||||
it('list-only account can see four-field history but cannot write', async () => {
|
||||
can.mockImplementation((...args: unknown[]) => args[0] === 'platform:firstleg:port-schedule:list')
|
||||
vi.mocked(getPortSchedule).mockResolvedValue({ ...result(), histories: [{ id: 'H1', action: 'UPDATE', reason: '改配', operatorName: '操作员', createdAt: '2026-10-03T00:00:00', beforeSnapshot: JSON.stringify(original), afterSnapshot: JSON.stringify({ ...original, vesselName: 'NEW' }) }] })
|
||||
const { wrapper, vm } = setup(); await vm.begin(); await flushPromises(); await vm.save()
|
||||
const { wrapper, vm } = setup(container, true); await vm.begin(); await flushPromises(); await vm.save()
|
||||
expect(wrapper.text()).toContain('NEW'); expect(wrapper.text()).toContain('操作员'); expect(wrapper.text()).toContain('改配')
|
||||
const dialog = wrapper.getComponent(AppDialog)
|
||||
expect(dialog.get('table[aria-label="当前船期只读资料"]').text()).toContain('OLD')
|
||||
expect(dialog.findAll('input, textarea')).toHaveLength(0)
|
||||
expect(dialog.props('showConfirm')).toBe(false)
|
||||
expect(updatePortSchedule).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('creation needs explicit source and dates and locks original ownership', async () => {
|
||||
const { wrapper, vm } = setup({ ...container, currentPortScheduleId: '' }); await vm.begin(); vm.form.reason = '首次登记'
|
||||
const { wrapper, vm } = setup({ ...container, currentPortScheduleId: '' }, true); await vm.begin(); vm.form.reason = '首次登记'
|
||||
await flushPromises()
|
||||
const dialog = wrapper.getComponent(AppDialog)
|
||||
expect(dialog.props('title')).toBe('登记原柜船期 · D1')
|
||||
expect(dialog.findAllComponents({ name: 'ElFormItem' })).toHaveLength(12)
|
||||
expect(dialog.get('table[aria-label="原柜船期归属"]').text()).toContain('平台订舱')
|
||||
await dialog.get('[aria-label="展开全屏"]').trigger('click')
|
||||
expect(vm.form.reason).toBe('首次登记')
|
||||
await vm.save(); expect(createPortSchedule).not.toHaveBeenCalled(); expect(vm.error).toContain('来源')
|
||||
Object.assign(vm.form, { sourceName: '平台人工确认', sourceRef: '船司邮件号', observedAt: '2026-10-03T00:00:00', expiresAt: '2026-10-05T00:00:00', portCutoffAt: '2026-10-03T01:00:00' })
|
||||
await vm.save(); expect(getPortSchedule).not.toHaveBeenCalled()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
@@ -13,6 +14,11 @@ const props = defineProps<{ container: {
|
||||
destinationPortCode: string | null; destinationPortName?: string | null
|
||||
estimatedSailingAt: string | null; estimatedPortArrivalAt: string | null
|
||||
} }>()
|
||||
const dialogTitle = computed(() => {
|
||||
const action = props.container.currentPortScheduleId ? '原柜船期与修改历史' : '登记原柜船期'
|
||||
const code = props.container.cabinetNo?.trim() || props.container.containerNo?.trim()
|
||||
return code ? `${action} · ${code}` : action
|
||||
})
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const canRead = computed(() => access.can('platform:firstleg:port-schedule:list'))
|
||||
@@ -101,37 +107,48 @@ onBeforeUnmount(() => { generation++ })
|
||||
<span v-if="prerequisite">{{ prerequisite }}</span>
|
||||
<span v-else-if="!canRead">当前账号无船期读取权限。</span>
|
||||
</div>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" :title="container.currentPortScheduleId ? '原柜船期与修改历史' : '登记原柜船期'" :width="container.currentPortScheduleId ? '1000px' : '1120px'" :show-confirm="canWrite" :confirm-disabled="loading || !!prerequisite || (!!container.currentPortScheduleId && !schedule)" :confirm-loading="busy" :confirm-text="pending ? '重试原请求' : '保存船期'" @confirm="save">
|
||||
<div v-loading="loading" class="schedule-body">
|
||||
<p>{{ container.cabinetNo || '—' }} · {{ container.containerNo || '—' }} · 提单 {{ container.billOfLadingNo || '—' }} · {{ container.customerName || '—' }}</p>
|
||||
<AppDialog v-model="open" class="admin-density-dialog" :title="dialogTitle" fullscreenable :width="container.currentPortScheduleId ? '1000px' : '1120px'" :show-confirm="canWrite" :confirm-disabled="loading || !!prerequisite || (!!container.currentPortScheduleId && !schedule)" :confirm-loading="busy" :confirm-text="pending ? '重试原请求' : '保存船期'" @confirm="save">
|
||||
<div v-loading="loading" class="density-dialog-sections">
|
||||
<el-alert v-if="error" :title="error" type="error" :closable="false" />
|
||||
<p v-if="!container.currentPortScheduleId">请登记本柜的船名、航次和预计时间,并按实际信息填写来源及有效期。</p>
|
||||
<el-form label-position="top" class="density-form" :disabled="!canWrite || busy || !!pending || loading">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="container.currentPortScheduleId ? 12 : 8"><el-form-item label="船名" required><el-input v-model="form.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item></el-col>
|
||||
<el-col :span="container.currentPortScheduleId ? 12 : 8"><el-form-item label="航次" required><el-input v-model="form.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item></el-col>
|
||||
<el-col v-for="key in dates" :key="key" :span="container.currentPortScheduleId ? 12 : 8"><el-form-item :label="dateLabels[key]" required><el-date-picker v-model="form[key]" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :placeholder="`请选择${dateLabels[key]}`" /></el-form-item></el-col>
|
||||
<template v-if="!container.currentPortScheduleId">
|
||||
<el-col :span="8"><el-form-item label="信息来源(人工登记)" required><el-input v-model="form.sourceName" maxlength="128" placeholder="请输入信息来源" /></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="来源参考号或链接" required><el-input v-model="form.sourceRef" maxlength="255" placeholder="请输入来源参考号或链接" /></el-form-item></el-col>
|
||||
<el-col v-for="key in extraDates" :key="key" :span="8"><el-form-item :label="dateLabels[key]" :required="!['bookingCutoffAt', 'customsCutoffAt'].includes(key)"><el-date-picker v-model="form[key]" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :placeholder="`请选择${dateLabels[key]}`" /></el-form-item></el-col>
|
||||
</template>
|
||||
<el-col :span="24"><el-form-item label="修改原因" required><el-input v-model="form.reason" type="textarea" maxlength="1000" placeholder="请说明本次修改原因" /></el-form-item></el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<h3>修改历史</h3>
|
||||
<el-table :data="schedule?.histories || []" empty-text="暂无修改历史">
|
||||
<el-table-column label="修改时间" min-width="170"><template #default="{ row }">{{ time(row.createdAt) }}</template></el-table-column>
|
||||
<el-table-column prop="operatorName" label="修改人" width="125" />
|
||||
<el-table-column prop="reason" label="原因" min-width="150" />
|
||||
<el-table-column label="修改前" min-width="240"><template #default="{ row }">{{ snapshot(row.beforeSnapshot) }}</template></el-table-column>
|
||||
<el-table-column label="修改后" min-width="240"><template #default="{ row }">{{ snapshot(row.afterSnapshot) }}</template></el-table-column>
|
||||
</el-table>
|
||||
<section class="density-dialog-section" data-tone="source">
|
||||
<table class="density-table schedule-context-table" aria-label="原柜船期归属"><tbody>
|
||||
<tr><th>柜次号</th><td>{{ container.cabinetNo || '—' }}</td><th>集装箱号</th><td>{{ container.containerNo || '—' }}</td></tr>
|
||||
<tr><th>提单号</th><td>{{ container.billOfLadingNo || '—' }}</td><th>客户</th><td>{{ container.customerName || '—' }}</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="work">
|
||||
<p v-if="!container.currentPortScheduleId" class="density-dialog-note">请登记本柜的船名、航次和预计时间,并按实际信息填写来源及有效期。</p>
|
||||
<el-form v-if="canWrite" class="density-form" :disabled="busy || !!pending || loading">
|
||||
<table class="density-table density-table--editable schedule-editor-table" aria-label="原柜船期填写"><tbody>
|
||||
<tr><td><el-form-item label="船名" label-position="left" label-width="120px" required><el-input v-model="form.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item></td><td><el-form-item label="航次" label-position="left" label-width="120px" required><el-input v-model="form.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item></td></tr>
|
||||
<tr><td v-for="key in dates" :key="key"><el-form-item :label="dateLabels[key]" label-position="left" label-width="120px" required><el-date-picker v-model="form[key]" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :placeholder="`请选择${dateLabels[key]}`" /></el-form-item></td></tr>
|
||||
<template v-if="!container.currentPortScheduleId">
|
||||
<tr><td><el-form-item label="信息来源" label-position="left" label-width="120px" required><el-input v-model="form.sourceName" maxlength="128" placeholder="请输入信息来源" /></el-form-item></td><td><el-form-item label="来源参考号或链接" label-position="left" label-width="120px" required><el-input v-model="form.sourceRef" maxlength="255" placeholder="请输入来源参考号或链接" /></el-form-item></td></tr>
|
||||
<tr v-for="(keys, index) in [extraDates.slice(0, 2), extraDates.slice(2, 4), extraDates.slice(4)]" :key="index"><td v-for="key in keys" :key="key" :colspan="keys.length === 1 ? 2 : 1"><el-form-item :label="dateLabels[key]" label-position="left" label-width="120px" :required="!['bookingCutoffAt', 'customsCutoffAt'].includes(key)"><el-date-picker v-model="form[key]" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :placeholder="`请选择${dateLabels[key]}`" /></el-form-item></td></tr>
|
||||
</template>
|
||||
<tr><td colspan="2"><el-form-item label="修改原因" label-position="left" label-width="120px" required><el-input v-model="form.reason" type="textarea" maxlength="1000" placeholder="请说明本次修改原因" /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</el-form>
|
||||
<table v-else class="density-table schedule-context-table" aria-label="当前船期只读资料"><tbody>
|
||||
<tr><th>船名</th><td>{{ form.vesselName || '—' }}</td><th>航次</th><td>{{ form.voyageNo || '—' }}</td></tr>
|
||||
<tr><template v-for="key in dates" :key="key"><th>{{ dateLabels[key] }}</th><td>{{ time(form[key]) }}</td></template></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="source">
|
||||
<h3 class="density-dialog-section__title">修改历史</h3>
|
||||
<el-table :data="schedule?.histories || []" empty-text="暂无修改历史">
|
||||
<el-table-column label="修改时间" min-width="170"><template #default="{ row }">{{ time(row.createdAt) }}</template></el-table-column>
|
||||
<el-table-column prop="operatorName" label="修改人" width="125" show-overflow-tooltip />
|
||||
<el-table-column prop="reason" label="原因" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="修改前" min-width="240"><template #default="{ row }">{{ snapshot(row.beforeSnapshot) }}</template></el-table-column>
|
||||
<el-table-column label="修改后" min-width="240"><template #default="{ row }">{{ snapshot(row.afterSnapshot) }}</template></el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
<style scoped>
|
||||
.schedule-actions { display:flex; align-items:center; gap:12px; margin:8px 0; }
|
||||
.schedule-body { max-height:65vh; overflow:auto; padding-inline:8px; }
|
||||
.schedule-body :deep(.el-date-editor) { width:100%; }
|
||||
.schedule-actions { display:flex; align-items:center; gap:var(--admin-space-5); margin:var(--admin-space-3) 0; }
|
||||
.schedule-editor-table, .schedule-context-table { table-layout:fixed; }
|
||||
.schedule-context-table th { width:120px; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user