Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb0b0f85d1 | ||
|
|
db3b5f7157 | ||
|
|
876930b07e | ||
|
|
0a1968a134 | ||
|
|
2f9a3330e5 |
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import './appointmentNativeForms.css'
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -122,11 +123,11 @@ onMounted(() => load())
|
||||
<p v-for="reason in context.blockReasons" :key="reason" role="status">{{ reason }}</p>
|
||||
<div v-if="!context.sources.length" aria-label="原批次货物快照"><p>当前批次无分批资格,以下为原预约保留的货物资料。</p><div v-for="(item, index) in context.appointment.items" :key="item.id" class="allocation-row"><b>来源 {{ index + 1 }} · {{ item.inboundCode || '原票业务号未提供' }}</b><p>{{ item.fbaWarehouseCode }} · {{ item.packageCount ?? '箱数待核实' }}<span v-if="item.packageCount != null"> 箱</span></p><p>货件:{{ item.shipmentReferences?.join('、') || '待核实' }}</p></div></div>
|
||||
<div class="batch-modes"><el-button :disabled="saving || !!pending || !context.allowedActions.includes('SPLIT')" @click="changeMode('split')">分成两次送仓</el-button><el-button :disabled="saving || !!pending || !context.allowedActions.includes('MERGE')" @click="changeMode('merge')">同仓合并安排</el-button></div>
|
||||
<form class="density-form" @submit.prevent="save"><fieldset :disabled="saving || !!pending || loading || !canWrite">
|
||||
<div v-for="(source, index) in context.sources" :key="allocationKey(source)" class="allocation-row"><b>来源 {{ index + 1 }} · {{ sourceLabel(source) }}</b><p>本批 {{ source.packageCount ?? '待核实' }} 箱 · 原箱数 {{ source.originalPackages ?? '未知' }}</p><p>货件:{{ source.shipmentReferences.join('、') || '待核实' }}</p><label v-if="mode === 'split'">分到第二批的箱数<input v-model="form.moved[allocationKey(source)]" type="number" min="0" :max="source.packageCount ?? undefined" step="1" :aria-label="`来源${index + 1}分出箱数`"></label><el-button v-if="context.allowedActions.includes('VERIFY_CARGO')" @click="verify(source)">核实本票资料</el-button></div>
|
||||
<template v-if="mode === 'merge'"><label>查找另一批工作事项<input v-model="keyword" placeholder="预约号、批次号、柜号" maxlength="80" @keydown.enter.prevent="searchCandidates()"></label><el-button :loading="candidateLoading" @click="searchCandidates()">查询批次</el-button><LoadErrorState v-if="candidateError" title="合并候选不可用" :message="candidateError.message" @retry="searchCandidates(candidatePage)" /><div v-for="candidate in candidates?.records.filter(item => item.batchKey && item.batchKey !== activeKey)" :key="candidate.workItemKey" class="batch-candidate"><span>{{ candidate.title }} · {{ candidate.destinationName || '目的仓待核实' }}</span><el-button :disabled="candidateLoading" @click="chooseOther(candidate.batchKey!)">核对并选择</el-button></div><p v-if="candidates && !candidates.records.length">本页未查询到工作事项。</p><div v-if="candidates && candidates.total > 8" class="batch-modes"><el-button :disabled="candidatePage === 1 || candidateLoading" @click="searchCandidates(candidatePage - 1)">上一页</el-button><span>第 {{ candidatePage }} 页</span><el-button :disabled="candidatePage * 8 >= candidates.total || candidateLoading" @click="searchCandidates(candidatePage + 1)">下一页</el-button></div><p v-if="other">已核对:{{ other.appointment.appointmentNo }},{{ other.sources.length }} 条来源;提交时再次逐来源校验。</p></template>
|
||||
<label>{{ mode === 'split' ? '第一批目标日期(可不填)' : '合并后目标日期(可不填)' }}<el-date-picker v-model="form.firstDate" type="date" :disabled="saving || !!pending || loading || !canWrite" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label v-if="mode === 'split'">第二批目标日期(可不填)<el-date-picker v-model="form.nextDate" type="date" :disabled="saving || !!pending || loading || !canWrite" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label>调整原因<textarea v-model="form.reason" required maxlength="1000" /></label><label>批次备注<textarea v-model="form.remark" maxlength="1000" /></label>
|
||||
</fieldset><LoadErrorState v-if="saveError" title="批次调整未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState><el-button type="primary" native-type="submit" :loading="saving" :disabled="!canSubmit || !!result">{{ pending ? '重试本次调整' : mode === 'split' ? '拆分为两批预约安排' : '合并为一批继续预约' }}</el-button></form>
|
||||
<form class="density-form appointment-native-form" @submit.prevent="save"><fieldset :disabled="saving || !!pending || loading || !canWrite">
|
||||
<div v-for="(source, index) in context.sources" :key="allocationKey(source)" class="allocation-row"><b>来源 {{ index + 1 }} · {{ sourceLabel(source) }}</b><p>本批 {{ source.packageCount ?? '待核实' }} 箱 · 原箱数 {{ source.originalPackages ?? '未知' }}</p><p>货件:{{ source.shipmentReferences.join('、') || '待核实' }}</p><label v-if="mode === 'split'">分到第二批的箱数<input v-model="form.moved[allocationKey(source)]" type="number" min="0" :max="source.packageCount ?? undefined" step="1" :aria-label="`来源${index + 1}分出箱数`" class="workbench-input"></label><el-button v-if="context.allowedActions.includes('VERIFY_CARGO')" @click="verify(source)">核实本票资料</el-button></div>
|
||||
<template v-if="mode === 'merge'"><label>查找另一批工作事项<input v-model="keyword" placeholder="预约号、批次号、柜号" maxlength="80" @keydown.enter.prevent="searchCandidates()" class="workbench-input"></label><el-button :loading="candidateLoading" @click="searchCandidates()">查询批次</el-button><LoadErrorState v-if="candidateError" title="合并候选不可用" :message="candidateError.message" @retry="searchCandidates(candidatePage)" /><div v-for="candidate in candidates?.records.filter(item => item.batchKey && item.batchKey !== activeKey)" :key="candidate.workItemKey" class="batch-candidate"><span>{{ candidate.title }} · {{ candidate.destinationName || '目的仓待核实' }}</span><el-button :disabled="candidateLoading" @click="chooseOther(candidate.batchKey!)">核对并选择</el-button></div><p v-if="candidates && !candidates.records.length">本页未查询到工作事项。</p><div v-if="candidates && candidates.total > 8" class="batch-modes"><el-button :disabled="candidatePage === 1 || candidateLoading" @click="searchCandidates(candidatePage - 1)">上一页</el-button><span>第 {{ candidatePage }} 页</span><el-button :disabled="candidatePage * 8 >= candidates.total || candidateLoading" @click="searchCandidates(candidatePage + 1)">下一页</el-button></div><p v-if="other">已核对:{{ other.appointment.appointmentNo }},{{ other.sources.length }} 条来源;提交时再次逐来源校验。</p></template>
|
||||
<label>{{ mode === 'split' ? '第一批目标日期(可不填)' : '合并后目标日期(可不填)' }}<el-date-picker v-model="form.firstDate" type="date" :disabled="saving || !!pending || loading || !canWrite" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label v-if="mode === 'split'">第二批目标日期(可不填)<el-date-picker v-model="form.nextDate" type="date" :disabled="saving || !!pending || loading || !canWrite" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label>调整原因<textarea v-model="form.reason" required maxlength="1000" class="workbench-input" /></label><label>批次备注<textarea v-model="form.remark" maxlength="1000" class="workbench-input" /></label>
|
||||
</fieldset><LoadErrorState v-if="saveError" title="批次调整未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState><div class="density-actions density-actions--footer appointment-form-actions"><el-button type="primary" native-type="submit" :loading="saving" :disabled="!canSubmit || !!result">{{ pending ? '重试本次调整' : mode === 'split' ? '拆分为两批预约安排' : '合并为一批继续预约' }}</el-button></div></form>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
@@ -2,11 +2,45 @@ import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
|
||||
import { scheduleTypes, type ScheduleEvent } from './schedule'
|
||||
import { projectTripCalendar, type TripCalendarEntry } from './tripCalendar'
|
||||
|
||||
const event = (type: ScheduleEvent['type'], index = 0): ScheduleEvent => ({ key: `${type}-${index}`, type, date: '2026-10-19', time: '', title: `TEST-CONT ${type}`, fields: [{ label: '仓库', value: 'ONT8' }] })
|
||||
const render = (events: ScheduleEvent[] = []) => mount(AppointmentCalendarPanel, { props: { initialDate: '2026-10-19', events },
|
||||
const render = (events: ScheduleEvent[] = [], initialDate = '2026-10-19') => mount(AppointmentCalendarPanel, { props: { initialDate, events },
|
||||
global: { stubs: { ElButton: { template: '<button><slot /></button>' } } } })
|
||||
function reportedDispatch() {
|
||||
const row: TripCalendarEntry = { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [] }
|
||||
return projectTripCalendar({ records: [row, row], unresolvedRecords: [] }, { from: '2026-10-01', to: '2026-11-01' }).events
|
||||
}
|
||||
describe('four-type appointment calendar', () => {
|
||||
it.each(['TSTU1234567', 'tstu1234567', 'TSTU7654321', ' tstu7654321 '])('finds the one shared dispatch by associated container %s on its origin-local date, not the Beijing date', async keyword => {
|
||||
const wrapper = render(reportedDispatch(), '2026-10-23')
|
||||
const search = wrapper.get('[aria-label="搜索当前日历"]')
|
||||
expect(search.attributes('placeholder')).toBe('柜号、排车单号、仓库')
|
||||
await search.setValue(keyword)
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(0)
|
||||
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('当天暂无安排')
|
||||
expect(wrapper.get('[data-date="2026-10-23"]').findAll('.event-chip')).toHaveLength(0)
|
||||
expect(wrapper.get('[data-date="2026-10-22"]').findAll('.event-chip')).toHaveLength(1)
|
||||
await wrapper.get('[data-date="2026-10-22"]').trigger('click')
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(1)
|
||||
expect(wrapper.get('.detail-event').text()).toContain('PC-CALENDAR-001')
|
||||
expect(wrapper.get('.detail-event').text()).toContain('09:00')
|
||||
expect(wrapper.get('.detail-event').text()).toContain('TSTU1234567、TSTU7654321')
|
||||
expect(wrapper.get('.detail-event').text()).toContain('2026-10-22 09:00')
|
||||
expect(wrapper.emitted('range')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('does not match an unrelated container and can find the same event by the dispatch number', async () => {
|
||||
const wrapper = render(reportedDispatch(), '2026-10-22')
|
||||
await wrapper.get('[aria-label="搜索当前日历"]').setValue('UNRELATED9999')
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(0)
|
||||
expect(wrapper.findAll('.event-chip')).toHaveLength(0)
|
||||
await wrapper.get('[aria-label="搜索当前日历"]').setValue('PC-CALENDAR-001')
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(1)
|
||||
expect(wrapper.findAll('.event-chip')).toHaveLength(1)
|
||||
expect(wrapper.emitted('range')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('shows all four types by default with a calendar and persistent daily detail', () => {
|
||||
const wrapper = render(scheduleTypes.map(type => event(type)))
|
||||
expect(wrapper.findAll('[aria-label="安排类型"] input')).toHaveLength(4)
|
||||
|
||||
@@ -56,7 +56,7 @@ function changeMode(next: CalendarMode) {
|
||||
</header>
|
||||
<div class="calendar-options">
|
||||
<div class="calendar-layers" role="group" aria-label="安排类型"><label v-for="type in scheduleTypes" :key="type" class="legend" :class="type"><input v-model="types" type="checkbox" :value="type">{{ scheduleLabels[type] }}</label></div>
|
||||
<label class="calendar-search"><span class="sr-only">搜索当前日历</span><input class="workbench-input" v-model="keyword" type="search" maxlength="128" placeholder="柜号、车次号、仓库" aria-label="搜索当前日历"></label>
|
||||
<label class="calendar-search"><span class="sr-only">搜索当前日历</span><input class="workbench-input" v-model="keyword" type="search" maxlength="128" placeholder="柜号、排车单号、仓库" aria-label="搜索当前日历"></label>
|
||||
<button class="undated-button" type="button" :aria-pressed="undated" @click="undated = !undated">未定日期 {{ grouped.get('undated')?.length || 0 }}</button>
|
||||
</div>
|
||||
<div class="weekdays"><span v-for="day in ['周一', '周二', '周三', '周四', '周五', '周六', '周日']" :key="day">{{ day }}</span></div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ElMessageBox } from 'element-plus'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import AppointmentCalendarView from './AppointmentCalendarView.vue'
|
||||
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type WarehouseArrival } from './scheduleApi'
|
||||
import { listTripCalendar } from './tripCalendar'
|
||||
import { listTripCalendar, type TripCalendarEntry } from './tripCalendar'
|
||||
import type { ScheduleEvent } from './schedule'
|
||||
const routing = vi.hoisted(() => ({ query: { date: '2026-10-19' } as Record<string, string>, push: vi.fn(), replace: vi.fn(), denied: new Set<string>() }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||
@@ -16,6 +16,9 @@ vi.mock('./tripCalendar', async original => ({ ...await original<typeof import('
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true }
|
||||
const row: ScheduleEvent = { key: 'warehouse-1', type: 'warehouse', date: '2026-10-21', time: '', title: 'TEST-CONT 计划到仓', fields: [], warehouse, canEdit: true }
|
||||
function dispatchEntry(overrides: Partial<TripCalendarEntry> = {}): TripCalendarEntry {
|
||||
return { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [], ...overrides }
|
||||
}
|
||||
export function renderSchedule() {
|
||||
return mount(AppointmentCalendarView, { global: { stubs: {
|
||||
ElDatePicker: DatePickerStub, ElTimePicker: TimePickerStub,
|
||||
@@ -147,7 +150,7 @@ describe('simplified appointment entry', () => {
|
||||
})
|
||||
it('opens the real dispatch detail with calendar context without requiring retired trip-list access', async () => {
|
||||
routing.denied.add('platform:fleet:trip:list')
|
||||
vi.mocked(listTripCalendar).mockResolvedValueOnce({ records: [{ sourceType: 'DISPATCH', dispatchNo: 'DS/1', status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: '美国仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-21T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [], incompleteReasons: [] }], unresolvedRecords: [] })
|
||||
vi.mocked(listTripCalendar).mockResolvedValueOnce({ records: [dispatchEntry({ dispatchNo: 'DS/1', containerNumbers: [], plannedDepartureAt: '2026-10-21T16:00:00Z' })], unresolvedRecords: [] })
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
||||
const event = panel.props('events').find((value: ScheduleEvent) => value.type === 'dispatch')
|
||||
@@ -158,4 +161,25 @@ describe('simplified appointment entry', () => {
|
||||
expect(routing.push).toHaveBeenCalledWith({ path: '/overseas-warehouse/dispatches/DS%2F1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('retains a complete dispatch with its container search fields while naming a different unresolved dispatch and its reason', async () => {
|
||||
const good = dispatchEntry()
|
||||
const unknown = dispatchEntry({ dispatchNo: 'PC-TIMEZONE-MISSING', containerNumbers: ['OTHER7654321'], originTimezone: null, incompleteReasons: ['ORIGIN_TIMEZONE_UNKNOWN'] })
|
||||
vi.mocked(listTripCalendar).mockResolvedValueOnce({ records: [good, good, unknown], unresolvedRecords: [] })
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
||||
const dispatches = (panel.props('events') as ScheduleEvent[]).filter(event => event.type === 'dispatch')
|
||||
expect(dispatches).toHaveLength(1)
|
||||
expect(dispatches[0]).toMatchObject({ key: 'dispatch-PC-CALENDAR-001', date: '2026-10-22', time: '09:00' })
|
||||
expect(dispatches[0]!.fields).toContainEqual({ label: '关联柜号', value: 'TSTU1234567、TSTU7654321' })
|
||||
expect(dispatches[0]!.fields).toContainEqual({ label: '计划出发(起运仓当地)', value: '2026-10-22 09:00' })
|
||||
expect(panel.props('incomplete')).toBe(true)
|
||||
const notice = wrapper.get('.data-notice').text()
|
||||
expect(notice).toContain('1 条排车未能定位到日历日期')
|
||||
expect(notice).toContain('PC-TIMEZONE-MISSING:起运仓时区待核实')
|
||||
expect(notice).toContain('无法确定当地日期')
|
||||
expect(notice).not.toContain('PC-CALENDAR-001')
|
||||
expect(dispatches.some(event => event.key === 'dispatch-PC-TIMEZONE-MISSING')).toBe(false)
|
||||
expect(listTripCalendar).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@ import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
|
||||
import { calendarRange, dateKey, type CalendarMode } from './calendar'
|
||||
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type CalendarSources, type ScheduleRange, type WarehouseArrival, type WarehouseArrivalChange } from './scheduleApi'
|
||||
import { projectContainerSchedule, type ScheduleEvent } from './schedule'
|
||||
import { listTripCalendar, projectTripCalendar } from './tripCalendar'
|
||||
import { listTripCalendar, projectTripCalendar, tripIssues } from './tripCalendar'
|
||||
import { rejectedCommand, type LeaveGuard } from './useWorkDraft'
|
||||
import type { PlanningSourceLookup } from './planningApi'
|
||||
import type { WorkAction } from './workApi'
|
||||
@@ -71,7 +71,10 @@ async function load(next = range.value) {
|
||||
const result = projectTripCalendar(await listTripCalendar(next, signal), next)
|
||||
if (request !== generation) return
|
||||
trips.value = result.events
|
||||
if (result.unresolved.length) notices.value.push(`${result.unresolved.length} 条排车资料不完整,请到排车列表核对。`)
|
||||
if (result.unresolved.length) {
|
||||
const details = result.unresolved.slice(0, 3).map(row => `${row.dispatchNo?.trim() || '排车单号缺失'}:${tripIssues(row) || '资料待核实'}`).join(';')
|
||||
notices.value.push(`${result.unresolved.length} 条排车未能定位到日历日期(${details}${result.unresolved.length > 3 ? ';其余请到排车列表核对' : ''})。`)
|
||||
}
|
||||
} catch (cause) { fail('排车读取失败', cause) }
|
||||
})(),
|
||||
])
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import './appointmentNativeForms.css'
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -60,13 +62,13 @@ onMounted(() => load())
|
||||
<template v-if="context">
|
||||
<dl class="cargo-facts"><dt>原票</dt><dd>{{ context.source.inboundCode || context.source.bookingNo || '—' }}</dd><dt>目的仓</dt><dd>{{ context.source.destinationWarehouseCode || '—' }}</dd><dt>原箱数</dt><dd>{{ context.source.packages ?? '待核实' }}</dd><dt>已分配</dt><dd>{{ context.allocatedPackages }} 箱<span v-if="context.unknownAllocationCount">;另有 {{ context.unknownAllocationCount }} 项数量未知</span></dd><dt>可分配</dt><dd>{{ context.availablePackages ?? '待核实' }}</dd></dl>
|
||||
<p v-if="context.sourceChanged" role="alert">原货源已变化,请核对当前原票和已分配量后重新保存。</p><p v-for="reason in context.blockReasons" :key="reason" role="status">{{ reason }}</p>
|
||||
<form class="density-form work-form" @submit.prevent="save"><fieldset :disabled="!canWrite || loading || !!error || saving || !!pending">
|
||||
<label>核实箱数(可暂不填)<input v-model="form.packages" type="number" min="1" step="1" aria-label="核实箱数"></label>
|
||||
<label>本票货件号 / 追踪号(每行一个)<textarea v-model="form.references" rows="4" aria-label="本票货件号" /></label>
|
||||
<label>核实说明<textarea v-model="form.remark" maxlength="1000" rows="2" /></label>
|
||||
<form class="density-form work-form appointment-native-form" @submit.prevent="save"><fieldset :disabled="!canWrite || loading || !!error || saving || !!pending">
|
||||
<label>核实箱数(可暂不填)<input v-model="form.packages" type="number" min="1" step="1" aria-label="核实箱数" class="workbench-input"></label>
|
||||
<label>本票货件号 / 追踪号(每行一个)<textarea v-model="form.references" rows="4" aria-label="本票货件号" class="workbench-input" /></label>
|
||||
<label>核实说明<textarea v-model="form.remark" maxlength="1000" rows="2" class="workbench-input" /></label>
|
||||
</fieldset><p v-if="!canWrite">当前仅可查看核实资料;需核实权限及有效货源资格才能保存。</p>
|
||||
<LoadErrorState v-if="saveError" title="核实资料未保存" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
<el-button type="primary" native-type="submit" :loading="saving" :disabled="!canWrite || loading || !!error">{{ pending ? '重试本次保存' : '保存核实资料' }}</el-button></form>
|
||||
<div class="density-actions density-actions--footer appointment-form-actions"><el-button type="primary" native-type="submit" :loading="saving" :disabled="!canWrite || loading || !!error">{{ pending ? '重试本次保存' : '保存核实资料' }}</el-button></div></form>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 预约工作日历逐操作矩阵
|
||||
|
||||
## 2026-10-21 排车候选分页
|
||||
|
||||
| 页面/组件 | 操作与承载依据 | 本次调整 | 验收 |
|
||||
|---|---|---|---|
|
||||
| WorkDispatchForm | 保留既有预约事项深链内的表单、批次范围与整车草稿流程;现行排车契约第17项候选分页 | 表下分页仅请求当前页;说明总数为关联计划候选数,表格仅显示本批货物;跨页保留选择和原批次版本,完整组/来源版本变化阻止建单 | 定向自动检查单独记录;1920×1080、100%待人工截图确认 |
|
||||
|
||||
## 2026-10-20 日期时间组件统一(当前确认)
|
||||
|
||||
依据:用户截图 `codex-clipboard-ed442cca-1bd2-43d3-87e8-6671e5bcc508.png`,以及随后明确要求排查并优化同类原生日期输入。保留现有业务入口与承载方式,使用 Element Plus 日期/时间控件及已有公共表单样式。
|
||||
@@ -265,3 +271,15 @@ V13 日历布局:按已确认定高要求,月日格固定 146px,周日格
|
||||
视觉目标:1920×1080、100%缩放,待人工截图确认。单元测试与类型检查单独报告,不代表真实接口或页面验收通过。原业务跳转缺少的业务号/客户范围在协作交接中列明,不由前端补造;工作 Slot 与原预约编辑均保留完整跨日时间。原业务页面返回入口只接受预约工作页,并保留原事项、前置事项、筛选及页码。
|
||||
|
||||
2026-10-20 用户追加整页白底修正:整页主要列表外层workbench-table及整页详情workbench-panel显式增加is-page-surface,由公共样式移除最外圈border/radius/shadow。内部指标、图表、并列卡片、子表格线、弹框和日历左右独立面板保留;本批不改padding、脚本或承载。预约页面没有额外整页面板,当前双面板结构保持,不给左右面板增加该modifier。
|
||||
|
||||
|
||||
### 2026-10-21 弹框表单可用性修复
|
||||
|
||||
依据:本轮弹框可用性反馈;沿用本文件原有逐操作承载,不调整业务字段与提交逻辑。
|
||||
|
||||
| 页面/文件 | 操作与承载 | 结构问题与修复 | 验收 |
|
||||
|---|---|---|---|
|
||||
| WorkCommandForm、AppointmentBatchForm、WorkReceivingPanel、CargoVerificationForm | 预约工作弹框中的命令、批次、收货范围与货物核实 | 原生控件未命中 Element 表单样式;显式复用 workbench-input,并通过 appointmentNativeForms.css 补齐标签/控件排列、fieldset 间距及底部提交区 | 定向回归通过;待人工1920×1080、100%截图确认 |
|
||||
| WorkAppointmentForm、WorkDispatchForm | 预约取消、按预约批次排车 | 取消原因与计划托位原生控件接入同一局部表单样式;复选框与既有候选分页保留 | 定向回归通过;待人工截图确认 |
|
||||
|
||||
本次6个Vue仅变更样式导入、class与操作区容器;与修改前快照逐项核对,业务脚本、v-model、事件、权限/禁用条件均一致。类型检查、全量回归与构建由总控统一收口。
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import './appointmentNativeForms.css'
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -57,7 +59,7 @@ async function save() {
|
||||
onMounted(() => load())
|
||||
</script>
|
||||
<template>
|
||||
<section class="work-form" aria-label="预约安排">
|
||||
<section class="work-form appointment-native-form" aria-label="预约安排">
|
||||
<h3>{{ cancel ? '取消本次预约' : rebooking ? '沿原批次重新预约' : '登记预约安排' }}</h3>
|
||||
<LoadErrorState v-if="error" title="预约详情读取失败" :message="error.message" :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="loading">正在读取原预约…</p>
|
||||
@@ -67,7 +69,7 @@ onMounted(() => load())
|
||||
<p v-if="replaced">原预约已被替代,保留历史只读。当前批次:{{ detail.replacementAppointmentNos?.join('、') || '—' }}。{{ detail.repartitionReason }}</p>
|
||||
<el-button v-if="detail.batchKey && !saving && !pending" @click="emit('batch', detail.batchKey)">{{ replaced ? '查看替代批次' : '分批 / 合并与货物来源' }}</el-button>
|
||||
<el-button v-if="detail.batchKey && !replaced && !saving && !pending" :disabled="loading || !!error" @click="emit('receiving', detail.batchKey)">客户调整 / 共同预约范围</el-button>
|
||||
<template v-if="cancel"><p>取消原预约并保留历史;工作需求与后续跟进仍保留。已有运输安排须满足原业务取消条件。</p><label>取消原因<textarea v-model="reason" :disabled="!writable || saving || !!pending" maxlength="1000" /></label></template>
|
||||
<template v-if="cancel"><p>取消原预约并保留历史;工作需求与后续跟进仍保留。已有运输安排须满足原业务取消条件。</p><label>取消原因<textarea v-model="reason" :disabled="!writable || saving || !!pending" maxlength="1000" class="workbench-input" /></label></template>
|
||||
<FbaAppointmentFields v-else :model="form" :appointment-no="detail.appointmentNo" :timezone="detail.timezone" :disabled="!writable || loading || !!error || saving || !!pending" />
|
||||
<p v-if="!writable">当前预约仅可查看。</p>
|
||||
<LoadErrorState v-if="saveError" title="预约未保存" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import './appointmentNativeForms.css'
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -86,20 +88,20 @@ async function save() {
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<form class="density-form work-form" @submit.prevent="save">
|
||||
<form class="density-form work-form appointment-native-form" @submit.prevent="save">
|
||||
<h3>{{ action.label }}<InlineHelp v-if="command === 'SAVE_FOLLOWUP'" title="跟进日期说明"><p>跟进日期不代替作业日期;未完成事项仍保留在待办中。</p></InlineHelp><InlineHelp v-if="command === 'RESOLVE_REVIEW'" title="核对结果说明"><p>原安排需要改期时,请先处理原安排,再记录核对结果。</p></InlineHelp></h3>
|
||||
<p v-if="!allowed">当前仅可查看。此操作需要有效的服务端资格和对应操作权限。</p>
|
||||
<p v-if="missingContext" role="alert">{{ missingContext }}</p>
|
||||
<fieldset :disabled="!allowed || refreshing || saving || !!pending">
|
||||
<template v-if="command === 'CONFIRM_ROUTE'"><label>运输方式<select v-model="form.flow"><option value="TRANSFER">先入海外仓,再分批送仓</option><option value="DIRECT">整柜直接送目的仓</option></select></label><label>路线说明<textarea v-model="form.reason" maxlength="1000" /></label></template>
|
||||
<template v-else-if="command === 'SAVE_FOLLOWUP'"><label>跟进负责人<select v-model="form.ownerId" required><option value="" disabled>请选择负责人</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>本次回复<textarea v-model="form.note" required maxlength="1000" /></label><label>下次跟进日期(可留空)<el-date-picker v-model="form.followupDate" type="date" :disabled="!allowed || refreshing || saving || !!pending" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label></template>
|
||||
<template v-else-if="command === 'RESOLVE_REVIEW'"><p v-for="reason in detail.review?.reasons" :key="reason">{{ reason }}</p><label>本次核对结果<textarea v-model="form.note" required maxlength="1000" /></label></template>
|
||||
<template v-else-if="command === 'SAVE_REQUIREMENT'"><label>目标日期(可暂不填)<el-date-picker v-model="form.date" type="date" :disabled="!allowed || refreshing || saving || !!pending" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><div class="time-fields"><label>开始时段(可留空)<el-time-picker v-model="form.startTime" :disabled="!allowed || refreshing || saving || !!pending" format="HH:mm" value-format="HH:mm" clearable /></label><label>结束日期(可留空)<el-date-picker v-model="form.endDate" type="date" :disabled="!allowed || refreshing || saving || !!pending" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label>结束时段(可留空)<el-time-picker v-model="form.endTime" :disabled="!allowed || refreshing || saving || !!pending" format="HH:mm" value-format="HH:mm" clearable /></label></div><p>{{ item.timezone || '地点时区待核实' }}</p><label v-if="item.type === 'DELIVERY'"><input v-model="form.bookingRequired" type="checkbox">需要收货方预约</label><label>收货要求<textarea v-model="form.receivingRequirements" :required="!form.bookingRequired" maxlength="1000" /></label><label>处理说明<textarea v-model="form.reason" required maxlength="1000" /></label></template>
|
||||
<template v-else-if="command === 'HANDOFF_RETURN'"><p>转交保留原预约和责任记录;接手方确认前持续跟进。</p><label>接手方类型<select v-model="form.recipientKind" @change="changeRecipient"><option value="EXTERNAL">外部车队 / 联系人</option><option value="SUPPLIER">已登记海外车队</option><option value="USER">当前关联负责人</option></select></label><label v-if="form.recipientKind === 'EXTERNAL'">接手方名称<input v-model="form.recipientName" required maxlength="200"></label><template v-else-if="form.recipientKind === 'SUPPLIER'"><label>查找海外车队<input v-model="supplierKeyword" maxlength="80" @keydown.enter.prevent="searchSuppliers"></label><el-button :loading="supplierLoading" @click="searchSuppliers">查询车队</el-button><LoadErrorState v-if="supplierError" title="还空车队不可用" :message="supplierError.message" @retry="searchSuppliers" /><label>接手车队<select v-model="form.recipientId" required :disabled="supplierLoading || !!supplierError"><option value="" disabled>请选择已登记海外车队</option><option v-for="supplier in suppliers" :key="supplier.id" :value="supplier.id">{{ supplier.name }}</option></select></label><p v-if="supplierTotal > suppliers.length">请输入更完整名称缩小查询范围。</p></template><label v-else>接手负责人<select v-model="form.recipientId" required><option value="" disabled>请选择</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>转交原因<textarea v-model="form.reason" required maxlength="1000" /></label></template>
|
||||
<template v-else-if="command === 'ACCEPT_RETURN'"><p>原责任方:{{ detail.returnHandoff?.from.name || '—' }} → 接手方:{{ detail.returnHandoff?.to.name || '—' }}</p><label>接手反馈依据<textarea v-model="form.note" :required="detail.returnHandoff?.to.kind !== 'USER'" maxlength="1000" /></label><p>确认接手只记录责任,实际还空仍按原业务操作。</p></template>
|
||||
<template v-if="command === 'CONFIRM_ROUTE'"><label>运输方式<select v-model="form.flow" class="workbench-input"><option value="TRANSFER">先入海外仓,再分批送仓</option><option value="DIRECT">整柜直接送目的仓</option></select></label><label>路线说明<textarea v-model="form.reason" maxlength="1000" class="workbench-input" /></label></template>
|
||||
<template v-else-if="command === 'SAVE_FOLLOWUP'"><label>跟进负责人<select v-model="form.ownerId" required class="workbench-input"><option value="" disabled>请选择负责人</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>本次回复<textarea v-model="form.note" required maxlength="1000" class="workbench-input" /></label><label>下次跟进日期(可留空)<el-date-picker v-model="form.followupDate" type="date" :disabled="!allowed || refreshing || saving || !!pending" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label></template>
|
||||
<template v-else-if="command === 'RESOLVE_REVIEW'"><p v-for="reason in detail.review?.reasons" :key="reason">{{ reason }}</p><label>本次核对结果<textarea v-model="form.note" required maxlength="1000" class="workbench-input" /></label></template>
|
||||
<template v-else-if="command === 'SAVE_REQUIREMENT'"><label>目标日期(可暂不填)<el-date-picker v-model="form.date" type="date" :disabled="!allowed || refreshing || saving || !!pending" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><div class="time-fields"><label>开始时段(可留空)<el-time-picker v-model="form.startTime" :disabled="!allowed || refreshing || saving || !!pending" format="HH:mm" value-format="HH:mm" clearable /></label><label>结束日期(可留空)<el-date-picker v-model="form.endDate" type="date" :disabled="!allowed || refreshing || saving || !!pending" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label>结束时段(可留空)<el-time-picker v-model="form.endTime" :disabled="!allowed || refreshing || saving || !!pending" format="HH:mm" value-format="HH:mm" clearable /></label></div><p>{{ item.timezone || '地点时区待核实' }}</p><label v-if="item.type === 'DELIVERY'"><input v-model="form.bookingRequired" type="checkbox">需要收货方预约</label><label>收货要求<textarea v-model="form.receivingRequirements" :required="!form.bookingRequired" maxlength="1000" class="workbench-input" /></label><label>处理说明<textarea v-model="form.reason" required maxlength="1000" class="workbench-input" /></label></template>
|
||||
<template v-else-if="command === 'HANDOFF_RETURN'"><p>转交保留原预约和责任记录;接手方确认前持续跟进。</p><label>接手方类型<select v-model="form.recipientKind" @change="changeRecipient" class="workbench-input"><option value="EXTERNAL">外部车队 / 联系人</option><option value="SUPPLIER">已登记海外车队</option><option value="USER">当前关联负责人</option></select></label><label v-if="form.recipientKind === 'EXTERNAL'">接手方名称<input v-model="form.recipientName" required maxlength="200" class="workbench-input"></label><template v-else-if="form.recipientKind === 'SUPPLIER'"><label>查找海外车队<input v-model="supplierKeyword" maxlength="80" @keydown.enter.prevent="searchSuppliers" class="workbench-input"></label><el-button :loading="supplierLoading" @click="searchSuppliers">查询车队</el-button><LoadErrorState v-if="supplierError" title="还空车队不可用" :message="supplierError.message" @retry="searchSuppliers" /><label>接手车队<select v-model="form.recipientId" required :disabled="supplierLoading || !!supplierError" class="workbench-input"><option value="" disabled>请选择已登记海外车队</option><option v-for="supplier in suppliers" :key="supplier.id" :value="supplier.id">{{ supplier.name }}</option></select></label><p v-if="supplierTotal > suppliers.length">请输入更完整名称缩小查询范围。</p></template><label v-else>接手负责人<select v-model="form.recipientId" required class="workbench-input"><option value="" disabled>请选择</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>转交原因<textarea v-model="form.reason" required maxlength="1000" class="workbench-input" /></label></template>
|
||||
<template v-else-if="command === 'ACCEPT_RETURN'"><p>原责任方:{{ detail.returnHandoff?.from.name || '—' }} → 接手方:{{ detail.returnHandoff?.to.name || '—' }}</p><label>接手反馈依据<textarea v-model="form.note" :required="detail.returnHandoff?.to.kind !== 'USER'" maxlength="1000" class="workbench-input" /></label><p>确认接手只记录责任,实际还空仍按原业务操作。</p></template>
|
||||
</fieldset>
|
||||
<LoadErrorState v-if="error" title="工作操作未保存" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-button type="primary" native-type="submit" :disabled="!allowed || refreshing || !!missingContext" :loading="saving">{{ pending ? '重试本次保存' : command === 'CONFIRM_ROUTE' ? '确认路线,继续本柜工作' : '保存并更新待办' }}</el-button>
|
||||
<div class="density-actions density-actions--footer appointment-form-actions"><el-button type="primary" native-type="submit" :disabled="!allowed || refreshing || !!missingContext" :loading="saving">{{ pending ? '重试本次保存' : command === 'CONFIRM_ROUTE' ? '确认路线,继续本柜工作' : '保存并更新待办' }}</el-button></div>
|
||||
</form>
|
||||
</template>
|
||||
<style scoped>.time-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3); }</style>
|
||||
|
||||
@@ -48,7 +48,7 @@ beforeEach(() => {
|
||||
natives = [candidate()]; draft = { draftToken: 'server-owned-token', recordVersion: 0, status: 'ACTIVE', warehouseId: 'origin-1', containers: [], sources: [], expiresAt: '2026-10-20T00:00:00Z' }
|
||||
vi.mocked(getReceivingContext).mockImplementation(async key => receiving(key))
|
||||
vi.mocked(getBatchDispatchCandidates).mockImplementation(async key => ({ batchKey: key, version: 4, candidates: [row('9007199254740993', 'customer-a', key)], blockReasons: [] }))
|
||||
vi.mocked(listDispatchCandidates).mockImplementation(async () => ({ candidates: clone(natives), complete: true, snapshotAt: '2026-10-19T00:00:00Z' }))
|
||||
vi.mocked(listDispatchCandidates).mockImplementation(async () => ({ total: 1, page: 1, size: 20, pages: 1, candidates: clone(natives), complete: true, snapshotAt: '2026-10-19T00:00:00Z' }))
|
||||
vi.mocked(createDispatchDraft).mockImplementation(async () => clone(draft))
|
||||
vi.mocked(createDispatchDraftContainer).mockImplementation(async body => { draft.recordVersion = 1; draft.containers = [{ draftContainerId: 'container-1', sequenceNo: 1, capacity: body.capacity, sourceKeys: [] }]; return clone(draft) })
|
||||
vi.mocked(lockDispatchDraftPallet).mockImplementation(async body => { const source = natives.find(value => value.selection.planPalletId === body.source.planPalletId)!; draft.recordVersion = Number(draft.recordVersion) + 1; draft.sources.push(clone(source)); draft.containers[0]!.sourceKeys.push(source.sourceKey); return clone(draft) })
|
||||
@@ -58,7 +58,7 @@ beforeEach(() => {
|
||||
})
|
||||
it('creates the whole dispatch when native Long candidate quantities arrive as strings and batch quantities are Integers', async () => {
|
||||
const actual = await vi.importActual<typeof import('../overseas-warehouse/api/dispatches')>('../overseas-warehouse/api/dispatches')
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValue(parseApiJsonResponse(JSON.stringify({ code: '200', data: { candidates: [{ ...candidate(), availablePackages: '12' }], complete: true, snapshotAt: '' } })))
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValue(parseApiJsonResponse(JSON.stringify({ code: '200', data: { total: 1, page: 1, size: 20, pages: 1, candidates: [{ ...candidate(), availablePackages: '12' }], complete: true, snapshotAt: '' } })))
|
||||
vi.mocked(listDispatchCandidates).mockImplementation(actual.listDispatchCandidates)
|
||||
const wrapper = render()
|
||||
try {
|
||||
@@ -98,7 +98,7 @@ it('uses a server-owned draft, explicit zero versions, native source hashes and
|
||||
})
|
||||
it('combines same-warehouse real customers in one container and keeps each batch source owner', async () => {
|
||||
const second = row('9007199254740994', 'customer-b'); natives.push(candidate(second)); vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [row(), second], blockReasons: [] })
|
||||
const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, true])
|
||||
const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input[type=checkbox]').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, true])
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const groups = vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups
|
||||
expect(groups).toHaveLength(1); expect(groups[0]!.dispatch.cargo).toEqual(natives.map(value => value.selection)); expect(groups[0]!.dispatch.batchAllocations.map(value => value.customerId)).toEqual(['customer-a', 'customer-b']); expect(wrapper.text()).not.toContain('共车'); wrapper.unmount()
|
||||
})
|
||||
@@ -112,12 +112,12 @@ it('keeps blocked, mismatched-customer, wrong-source and unknown-quantity cargo
|
||||
const blocked = { ...row('2'), blockReasons: ['本托已被其他排车占用'] }, wrong = row('3'), unknown = { ...row('4'), packageCount: null }, wrongSource = row('5')
|
||||
wrong.allocations[0]!.customerId = 'customer-b'; wrongSource.allocations[0]!.sourceKey = 'ORDER:unrelated'; natives.push(...[blocked, wrong, unknown, wrongSource].map(value => candidate(value)))
|
||||
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [row(), blocked, wrong, unknown, wrongSource], blockReasons: [] })
|
||||
const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, false, false, false, false])
|
||||
const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input[type=checkbox]').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, false, false, false, false])
|
||||
expect(wrapper.text()).toContain('本托已被其他排车占用'); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual(row().allocations); wrapper.unmount()
|
||||
})
|
||||
it('fails closed on incomplete native candidates, missing versions, and cross-warehouse batches', async () => {
|
||||
vi.mocked(listDispatchCandidates).mockResolvedValueOnce({ candidates: natives, complete: false, snapshotAt: '' }); const incomplete = render(); await flushPromises(); expect(incomplete.text()).toContain('未完整返回'); expect(createDispatchDraft).not.toHaveBeenCalled(); incomplete.unmount()
|
||||
natives[0]!.selection.sourceVersion = ''; const missing = render(); await flushPromises(); expect(missing.get('input').attributes('disabled')).toBeDefined(); missing.unmount()
|
||||
it('accepts partial pages but fails closed on missing versions and cross-warehouse batches', async () => {
|
||||
vi.mocked(listDispatchCandidates).mockResolvedValueOnce({ total: 1, page: 1, size: 20, pages: 1, candidates: natives, complete: false, snapshotAt: '' }); const incomplete = render(); await flushPromises(); expect(incomplete.get('input[type=checkbox]').attributes('disabled')).toBeUndefined(); expect(createDispatchDraft).not.toHaveBeenCalled(); incomplete.unmount()
|
||||
natives[0]!.selection.sourceVersion = ''; const missing = render(); await flushPromises(); expect(missing.text()).toContain('来源版本不完整'); missing.unmount()
|
||||
vi.mocked(getReceivingContext).mockImplementation(async key => { const value = receiving(key); if (key !== batch) value.batch.originWarehouseId = 'another-warehouse'; return value })
|
||||
const cross = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(cross.text()).toContain('相同的起运仓'); expect(convertDispatchDraft).not.toHaveBeenCalled(); cross.unmount()
|
||||
})
|
||||
@@ -160,3 +160,38 @@ it('shows batch-level blockers even without rows and refuses duplicate physical
|
||||
const blocked = render(); await flushPromises(); expect(blocked.get('[role="alert"]').text()).toContain('原批次已替代'); expect(createDispatchDraft).not.toHaveBeenCalled(); blocked.unmount()
|
||||
const duplicate = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(duplicate.findAll('input').every(input => input.attributes('disabled') !== undefined)).toBe(true); expect(duplicate.text()).toContain('同一实物'); expect(convertDispatchDraft).not.toHaveBeenCalled(); duplicate.unmount()
|
||||
})
|
||||
|
||||
it('keeps original batch allocations and selected cargo while paging through associated plan candidates', async () => {
|
||||
const first = row(), second = row('9007199254740994', 'customer-b')
|
||||
natives = [candidate(first), candidate(second)]
|
||||
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [first, second], blockReasons: [] })
|
||||
vi.mocked(listDispatchCandidates).mockImplementation(async query => ({ total: 21, page: query.page!, size: query.size!, pages: 2, candidates: [clone(natives[query.page === 2 ? 1 : 0]!)], complete: false, snapshotAt: '' }))
|
||||
const wrapper = render()
|
||||
try {
|
||||
await flushPromises(); expect(wrapper.findAll('input[type=checkbox]')).toHaveLength(1)
|
||||
await wrapper.get('input[type=checkbox]').setValue(true)
|
||||
const state = wrapper.vm as unknown as { changeCandidatePage(page: number): void }
|
||||
state.changeCandidatePage(2); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
|
||||
expect(listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, size: 20, warehouseTab: 'ALL', warehouseId: 'origin-1' }))
|
||||
expect(getBatchDispatchCandidates).toHaveBeenCalledTimes(1)
|
||||
expect(listDispatchCandidates).toHaveBeenCalledTimes(2)
|
||||
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
|
||||
expect(convertDispatchDraft).toHaveBeenCalledTimes(1)
|
||||
expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual([...first.allocations, ...second.allocations])
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
it('rejects a selected batch source whose group version changed on another candidate page', async () => {
|
||||
vi.mocked(listDispatchCandidates).mockImplementation(async query => {
|
||||
const value = clone(natives[0]!)
|
||||
if (query.page === 2) value.selectionGroup.groupVersion = 'new-group-version'
|
||||
return { total: 21, page: query.page!, size: query.size!, pages: 2, candidates: [value], complete: false, snapshotAt: '' }
|
||||
})
|
||||
const wrapper = render()
|
||||
try {
|
||||
await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
|
||||
;(wrapper.vm as unknown as { changeCandidatePage(page: number): void }).changeCandidatePage(2)
|
||||
await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('选货组组成已变化')
|
||||
expect(createDispatchDraft).not.toHaveBeenCalled()
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import './appointmentNativeForms.css'
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, dispatchVehicleSpecs, DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection, type BatchDispatchCandidate, type BatchDispatchCandidates, type Candidate, type DispatchDetail, type DispatchDraft, type ConvertDraft, type ConvertedDraft, type DraftSelection, type VersionCommand } from '@/modules/overseas-warehouse'
|
||||
import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, useDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, dispatchVehicleSpecs, DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection, type BatchDispatchCandidate, type BatchDispatchCandidates, type Candidate, type DispatchDetail, type DispatchDraft, type ConvertDraft, type ConvertedDraft, type DraftSelection, type VersionCommand } from '@/modules/overseas-warehouse'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
@@ -21,14 +23,20 @@ type Preparation = { warehouseId: string; capacity: number; rows: ReadyRow[]; di
|
||||
const access = useAccessPreviewStore(), auth = useAuthStore(), owner = auth.user?.id || ''
|
||||
const scope = JSON.stringify([...new Set(props.batchKeys)].sort()), storageKey = `appointment-dispatch:${owner}:${scope}`
|
||||
const contexts = ref<BatchDispatchCandidates[]>([]), rows = ref<BatchRow[]>([]), selected = ref<string[]>([]), warehouseId = ref('')
|
||||
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
|
||||
const working = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
|
||||
const candidatePage = useDispatchCandidates(error, listDispatchCandidates)
|
||||
const { page: candidatePageNumber, size: candidatePageSize, total: candidateTotal } = candidatePage
|
||||
const loading = computed(() => working.value || candidatePage.loading.value)
|
||||
const selectedRows = ref<Record<string, BatchRow>>({})
|
||||
let receivingContexts: Awaited<ReturnType<typeof getReceivingContext>>[] = []
|
||||
let nativeGeneration = 0
|
||||
const capacity = ref(30), result = ref<DispatchDetail | null>(null), pending = ref<Preparation | null>(null), corrupted = ref(false)
|
||||
const adding = ref(false), addingBusy = ref(false), arrangingRemaining = ref(false), existingReady = ref(false)
|
||||
const existing = ref<Array<{ batchKey: string; dispatchNo: string; customerId: string }>>([]), baseline = ref('[]')
|
||||
const readable = computed(() => ['platform:appointment:list', 'platform:overseas-warehouse:last-mile-order:list', 'platform:overseas-warehouse:inbound-order:list', 'platform:overseas-warehouse:dispatch:list'].every(permission => access.can(permission)))
|
||||
const canCreate = computed(() => readable.value && access.can('platform:overseas-warehouse:dispatch:create'))
|
||||
const writable = computed(() => props.canWrite && canCreate.value && !corrupted.value && (!existing.value.length || existingReady.value))
|
||||
const chosen = computed(() => rows.value.filter(row => selected.value.includes(row.key)))
|
||||
const chosen = computed(() => selected.value.map(key => selectedRows.value[key]).filter((row): row is BatchRow => Boolean(row)))
|
||||
const unresolved = computed(() => saving.value || Boolean(pending.value) || corrupted.value)
|
||||
useWorkDraft(computed(() => JSON.stringify(selected.value) !== baseline.value && !result.value), unresolved)
|
||||
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
||||
@@ -43,7 +51,7 @@ function detailPath(no: string) { return `/overseas-warehouse/dispatches/${encod
|
||||
async function load() {
|
||||
if (!readable.value || pending.value || corrupted.value) return
|
||||
const current = ++generation, batches = [...new Set(props.batchKeys)]
|
||||
loading.value = true; error.value = null; existingReady.value = false; rows.value = []; selected.value = []; baseline.value = '[]'; contexts.value = []
|
||||
working.value = true; error.value = null; existingReady.value = false; rows.value = []; selected.value = []; baseline.value = '[]'; contexts.value = []; selectedRows.value = {}; candidatePage.reset(); nativeGeneration++
|
||||
try {
|
||||
if (!batches.length || batches.some(key => !/^FBA_APPOINTMENT:\d+$/.test(key))) throw new DispatchInputError('请从真实预约批次进入排车,不能重组其他业务批次')
|
||||
const receiving = await Promise.all(batches.map(key => getReceivingContext(key)))
|
||||
@@ -66,16 +74,24 @@ async function load() {
|
||||
if (current !== generation) return
|
||||
if (batchCandidates.some((context, index) => context.batchKey !== batches[index])) throw new DispatchInputError('实物候选与当前预约批次不一致')
|
||||
requireBatchVersions(batchCandidates.flatMap(context => context.candidates.flatMap(row => row.allocations)))
|
||||
contexts.value = batchCandidates
|
||||
const planIds = [...new Set(batchCandidates.flatMap(context => context.candidates.map(row => row.planId)))]
|
||||
const native = planIds.length ? await listDispatchCandidates({ warehouseId: warehouseId.value, planIds }) : { candidates: [], complete: true }
|
||||
if (current !== generation) return
|
||||
requireBatchVersions([...batchCandidates.flatMap(context => context.candidates.flatMap(row => row.allocations)), ...native.candidates.flatMap(row => row.appointmentBatches)])
|
||||
if (native.complete !== true) throw new DispatchInputError('本仓原生候选未完整返回,不能按部分结果建单')
|
||||
rows.value = batchCandidates.flatMap((context, index) => context.candidates.map(row => {
|
||||
const matches = native.candidates.filter(value => value.selection.sourceType === 'INBOUND_PLAN' && value.selection.planPalletId === row.planPalletId && value.customerId === row.customerId)
|
||||
contexts.value = batchCandidates; receivingContexts = receiving
|
||||
await loadCandidatePage(true)
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '批次可排实物读取失败') }
|
||||
finally { if (current === generation) working.value = false }
|
||||
}
|
||||
async function loadCandidatePage(autoSelect = false) {
|
||||
const sequence = ++nativeGeneration, current = generation
|
||||
const planIds = [...new Set(contexts.value.flatMap(context => context.candidates.map(row => row.planId)))]
|
||||
if (!planIds.length) { candidatePage.reset(); rows.value = []; return }
|
||||
await candidatePage.load({ warehouseId: warehouseId.value, planIds, page: candidatePageNumber.value, size: candidatePageSize.value, warehouseTab: 'ALL' })
|
||||
if (sequence !== nativeGeneration || current !== generation) return
|
||||
if (error.value) { rows.value = []; return }
|
||||
try {
|
||||
requireBatchVersions(candidatePage.candidates.value.flatMap(row => row.appointmentBatches))
|
||||
rows.value = contexts.value.flatMap((context, index) => context.candidates.map(row => {
|
||||
const matches = candidatePage.candidates.value.filter(value => value.selection.sourceType === 'INBOUND_PLAN' && value.selection.planPalletId === row.planPalletId && value.customerId === row.customerId)
|
||||
const candidate = matches.length === 1 ? matches[0]! : null
|
||||
const sources = receiving[index]!.batch.items
|
||||
const sources = receivingContexts[index]!.batch.items
|
||||
const blocks = [...context.blockReasons, ...row.blockReasons]
|
||||
if (!candidate || candidate.warehouseId !== warehouseId.value || !candidate.sourceVersion || candidate.selection.sourceVersion !== candidate.sourceVersion) blocks.push('原生实物来源、起运仓或版本待核实')
|
||||
if (candidate) { const groupBlock = selectionGroupBlockReason(candidate); if (groupBlock) blocks.push(groupBlock) }
|
||||
@@ -84,20 +100,25 @@ async function load() {
|
||||
if (row.allocations.reduce((sum, allocation) => sum + allocation.packageCount, 0) !== row.packageCount) blocks.push('逐来源批次分配与实物箱数不一致')
|
||||
return { ...row, key: `${context.batchKey}:${row.customerId}:${row.planPalletId}`, batchKey: context.batchKey, native: candidate, blockReasons: [...new Set(blocks)] }
|
||||
}))
|
||||
if (candidateTotal.value > candidatePageSize.value) rows.value = rows.value.filter(row => row.native)
|
||||
for (const row of rows.value) if (row.native && rows.value.filter(other => other.native?.sourceKey === row.native!.sourceKey).length > 1) row.blockReasons.push('同一实物出现在多条批次候选,请先核对分配范围')
|
||||
const eligible = rows.value.filter(row => !reasons(row).length)
|
||||
selected.value = batches.length === 1 && physicalCount(eligible) <= capacity.value ? eligible.map(row => row.key) : []
|
||||
baseline.value = JSON.stringify(selected.value)
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '批次可排实物读取失败') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
if (autoSelect && candidateTotal.value <= candidatePageSize.value) {
|
||||
const eligible = rows.value.filter(row => !reasons(row).length)
|
||||
selected.value = props.batchKeys.length === 1 && physicalCount(eligible) <= capacity.value ? eligible.map(row => row.key) : []
|
||||
selectedRows.value = Object.fromEntries(eligible.filter(row => selected.value.includes(row.key)).map(row => [row.key, clone(row)]))
|
||||
baseline.value = JSON.stringify(selected.value)
|
||||
}
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '批次可排实物读取失败') }
|
||||
}
|
||||
function changeCandidatePage(page: number) { candidatePageNumber.value = page; void loadCandidatePage() }
|
||||
function changeCandidateSize(size: number) { candidatePageSize.value = size; candidatePageNumber.value = 1; void loadCandidatePage() }
|
||||
function select(row: BatchRow, event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (!writable.value || unresolved.value || loading.value || error.value || reasons(row).length) { input.checked = selected.value.includes(row.key); return }
|
||||
if (!input.checked) selected.value = selected.value.filter(key => key !== row.key)
|
||||
else if (!selected.value.includes(row.key)) {
|
||||
if (physicalCount([...chosen.value, row]) > capacity.value) ElMessage.warning('已达到本组计划托位上限,请核对实际托盘')
|
||||
else selected.value.push(row.key)
|
||||
else { selectedRows.value[row.key] = clone(row); selected.value.push(row.key) }
|
||||
}
|
||||
input.checked = selected.value.includes(row.key)
|
||||
}
|
||||
@@ -153,6 +174,7 @@ async function save() {
|
||||
ensureOwner()
|
||||
if (!pending.value) {
|
||||
if (!writable.value || error.value || result.value || !chosen.value.length || chosen.value.some(row => reasons(row).length || !row.native) || physicalCount(chosen.value) > capacity.value) throw new DispatchInputError('请核对本仓实物资格、来源批次与计划托位')
|
||||
candidatePage.assertCurrent(chosen.value.map(row => row.native!))
|
||||
pending.value = { warehouseId: warehouseId.value, capacity: capacity.value, rows: clone(chosen.value) as ReadyRow[], dispatchKey: crypto.randomUUID(), draft: null, containerId: null, lockedKeys: [], step: null, converted: null, releasing: false }; persist()
|
||||
}
|
||||
saving.value = true
|
||||
@@ -200,10 +222,10 @@ async function releaseRejected() {
|
||||
}
|
||||
async function remaining() { if (!unresolved.value && !addingBusy.value) { result.value = null; adding.value = false; arrangingRemaining.value = true; await load() } }
|
||||
onMounted(load)
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
onBeforeUnmount(() => { generation++; nativeGeneration++; candidatePage.reset() })
|
||||
</script>
|
||||
<template>
|
||||
<section class="work-form" aria-label="按预约批次创建整车排车">
|
||||
<section class="work-form appointment-native-form" aria-label="按预约批次创建整车排车">
|
||||
<h3>{{ existing.length ? '沿用本批已有排车单' : '按批次选择同仓真实货物' }} <InlineHelp title="排车说明"><p>同一张排车单可装同仓多个客户与目的仓,客户和预约归属逐来源保留。</p></InlineHelp></h3>
|
||||
<p v-if="!readable">读取批次实物需要预约、尾程订单、入仓单和排车列表权限。</p>
|
||||
<LoadErrorState v-if="error" title="批次可排实物读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
@@ -216,8 +238,10 @@ onBeforeUnmount(() => { generation++ })
|
||||
<template v-else-if="!pending && !corrupted && readable && !loading && !error && (!existing.length || arrangingRemaining && existingReady)">
|
||||
<p v-for="reason in [...new Set(contexts.flatMap(context => context.blockReasons))]" :key="reason" role="alert">{{ reason }}</p>
|
||||
<el-button v-if="batchKeys.length === 1" :disabled="!writable || unresolved" @click="emit('batch', batchKeys[0]!)">按当前批次箱数分批</el-button><p>部分箱数先按本批逐来源数量分批;不对应完整实物时,先在原库存作业拆托。</p>
|
||||
<label>本组计划托位<select v-model.number="capacity" :disabled="!writable || unresolved"><option v-for="spec in dispatchVehicleSpecs" :key="spec.value" :value="spec.capacity">{{ spec.label }}</option></select></label>
|
||||
<label>本组计划托位<select v-model.number="capacity" :disabled="!writable || unresolved" class="workbench-input"><option v-for="spec in dispatchVehicleSpecs" :key="spec.value" :value="spec.capacity">{{ spec.label }}</option></select></label>
|
||||
<table class="density-table"><thead><tr><th>选择</th><th>客户</th><th>托盘码</th><th>入库单号</th><th>来源</th><th>箱数</th><th>不可选择原因</th></tr></thead><tbody><tr v-for="(row, index) in rows" :key="`${row.key}:${index}`"><td><input type="checkbox" :checked="selected.includes(row.key)" :disabled="!writable || unresolved || reasons(row).length > 0" @change="select(row, $event)"></td><td>{{ row.native?.customerName || '客户待核实' }}</td><td>{{ row.palletNo || '—' }}</td><td>{{ row.planNo || '—' }}</td><td>{{ row.sourceStage === 'RECEIVED' ? '已收货实物' : '计划托盘' }}</td><td>{{ row.packageCount ?? '待核实' }}</td><td>{{ reasons(row).join(';') || '可选' }}</td></tr></tbody></table><p v-if="!rows.length">当前批次尚无可排实物。</p>
|
||||
<el-pagination aria-label="批次关联候选分页" :current-page="candidatePageNumber" :page-size="candidatePageSize" :page-sizes="[10, 20, 50]" :total="candidateTotal" :disabled="unresolved || loading" layout="total, sizes, prev, pager, next" @update:current-page="changeCandidatePage" @update:page-size="changeCandidateSize" />
|
||||
<p>上方总数为关联计划的候选数;当前页展示其中属于本批次的实物。已选 {{ chosen.length }} 条(含其他页)。</p>
|
||||
<el-button type="primary" :disabled="!writable || !chosen.length" :loading="saving" @click="save">创建整车排车单</el-button>
|
||||
</template>
|
||||
<p v-if="pending" role="status">本次草稿与原请求已保留,请勿改变来源、版本或另建排车单。<el-button :disabled="!canCreate || saving" :loading="saving" @click="save">核对并重试原请求</el-button></p>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import './appointmentNativeForms.css'
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
@@ -265,7 +266,7 @@ onMounted(async () => { await load(); if (props.initialCustomerId && customers.v
|
||||
<ReceivingScope :members="[]" :transport="context.transport" />
|
||||
<el-button :disabled="locked || !edit || !context.allowedActions.includes('BOOK_RECEIVING')" @click="newBooking">登记当前未覆盖货物的正式预约</el-button>
|
||||
</template>
|
||||
<form v-else class="density-form" @submit.prevent="save()">
|
||||
<form v-else class="density-form appointment-native-form" @submit.prevent="save()">
|
||||
<fieldset :disabled="locked || !formWritable">
|
||||
<template v-if="mode === 'refresh'">
|
||||
<h4>{{ customerName(customer) || '—' }} · 核对本客户最新货物资料</h4><p>采用后仅更新本客户的资料快照,本批箱数和其他客户的确认保持不变;保存核实资料不会自动执行采用。</p>
|
||||
@@ -283,19 +284,19 @@ onMounted(async () => { await load(); if (props.initialCustomerId && customers.v
|
||||
<label>目标送仓日期<el-date-picker v-model="planForm.plannedDate" type="date" aria-label="客户计划日期" :disabled="locked || !formWritable" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label>
|
||||
<label>计划开始时段<el-date-picker v-model="planForm.plannedAt" @change="planForm.startOffset = ''" type="datetime" aria-label="客户计划开始" :disabled="locked || !formWritable" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DDTHH:mm:ss" clearable /></label>
|
||||
<label>计划结束时段<el-date-picker v-model="planForm.plannedEndAt" @change="planForm.endOffset = ''" type="datetime" aria-label="客户计划结束" :disabled="locked || !formWritable" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DDTHH:mm:ss" clearable /></label>
|
||||
<details><summary>计划时段的夏令时信息</summary><p>当地时钟回拨导致时刻重复时,请按仓库确认填写对应时差。跳时期间不存在的当地时刻不可用,请选择有效时间。</p><label>计划开始时差<input v-model="planForm.startOffset" aria-label="客户计划开始时差" placeholder="如 -07:00"></label><label>计划结束时差<input v-model="planForm.endOffset" aria-label="客户计划结束时差" placeholder="如 -08:00"></label></details>
|
||||
<label>本次联系结果<select v-model="planForm.appointmentStatus" aria-label="客户计划状态"><option value="PENDING">等待对方确认</option><option value="FAILED">未约成</option></select></label>
|
||||
<label>{{ planForm.appointmentStatus === 'FAILED' ? '未约成原因' : '联系备注' }}<textarea v-model="planForm.appointmentRemark" maxlength="1000" aria-label="客户计划备注" /></label>
|
||||
<details><summary>计划时段的夏令时信息</summary><p>当地时钟回拨导致时刻重复时,请按仓库确认填写对应时差。跳时期间不存在的当地时刻不可用,请选择有效时间。</p><label>计划开始时差<input v-model="planForm.startOffset" aria-label="客户计划开始时差" placeholder="如 -07:00" class="workbench-input"></label><label>计划结束时差<input v-model="planForm.endOffset" aria-label="客户计划结束时差" placeholder="如 -08:00" class="workbench-input"></label></details>
|
||||
<label>本次联系结果<select v-model="planForm.appointmentStatus" aria-label="客户计划状态" class="workbench-input"><option value="PENDING">等待对方确认</option><option value="FAILED">未约成</option></select></label>
|
||||
<label>{{ planForm.appointmentStatus === 'FAILED' ? '未约成原因' : '联系备注' }}<textarea v-model="planForm.appointmentRemark" maxlength="1000" aria-label="客户计划备注" class="workbench-input" /></label>
|
||||
|
||||
</template>
|
||||
<template v-if="mode === 'customer'"><h4>仅本客户调整,其他客户保持原安排</h4><div v-for="item in customerItems" :key="item.id"><p>{{ item.inboundCode || '原票业务号未提供' }} · 当前 {{ item.packageCount ?? '待核实' }} 箱</p><label v-if="item.destinationKey">本次先送箱数<input v-model="adjustment.retained[sourceKey({ sourceKey: item.sourceKey, destinationKey: item.destinationKey })]" type="number" min="0" :max="item.packageCount ?? undefined" step="1" aria-label="本次先送箱数"></label></div><label>后送目标日期<el-date-picker v-model="adjustment.laterDate" type="date" :disabled="locked || !formWritable" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label>负责人<select v-model="adjustment.ownerId" required aria-label="调整负责人"><option value="" disabled>请选择负责人</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>下一次跟进日期<el-date-picker v-model="adjustment.followup" type="date" :disabled="locked || !formWritable" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label>调整原因<textarea v-model="adjustment.reason" maxlength="1000" aria-label="调整原因" /></label><p>保存仅登记待确认变化,原箱数与原确认仍保留,相关发车暂受阻。</p></template>
|
||||
<template v-if="mode === 'resolve' && change"><h4>仓库回复:本次客户调整</h4><ReceivingScope :members="change.originalMembers" :transport="context.transport" /><p v-for="(quantity, index) in change.intent.quantities" :key="sourceKey(quantity)">货物 {{ index + 1 }} · 本次先送 {{ quantity.retainedPackages }} 箱</p><p>负责人 {{ change.followup.ownerName || '—' }} · 跟进 {{ change.followup.nextFollowupDate || '待安排' }}</p><p>后送日期 {{ change.intent.laterDate || '未定' }} · {{ change.intent.reason }}</p><p v-for="agreement in change.originalAgreements" :key="agreement.agreementKey">原约号 {{ agreement.details.confirmationNo || '—' }} · {{ agreement.details.appointmentAt || '时段未定' }} · {{ agreement.timezone || '时区待核实' }}</p><label>仓库同意保留量和移出量的回复依据<textarea v-model="evidence" maxlength="1000" aria-label="仓库回复依据" /></label></template>
|
||||
<template v-if="mode === 'customer'"><h4>仅本客户调整,其他客户保持原安排</h4><div v-for="item in customerItems" :key="item.id"><p>{{ item.inboundCode || '原票业务号未提供' }} · 当前 {{ item.packageCount ?? '待核实' }} 箱</p><label v-if="item.destinationKey">本次先送箱数<input v-model="adjustment.retained[sourceKey({ sourceKey: item.sourceKey, destinationKey: item.destinationKey })]" type="number" min="0" :max="item.packageCount ?? undefined" step="1" aria-label="本次先送箱数" class="workbench-input"></label></div><label>后送目标日期<el-date-picker v-model="adjustment.laterDate" type="date" :disabled="locked || !formWritable" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label>负责人<select v-model="adjustment.ownerId" required aria-label="调整负责人" class="workbench-input"><option value="" disabled>请选择负责人</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>下一次跟进日期<el-date-picker v-model="adjustment.followup" type="date" :disabled="locked || !formWritable" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label>调整原因<textarea v-model="adjustment.reason" maxlength="1000" aria-label="调整原因" class="workbench-input" /></label><p>保存仅登记待确认变化,原箱数与原确认仍保留,相关发车暂受阻。</p></template>
|
||||
<template v-if="mode === 'resolve' && change"><h4>仓库回复:本次客户调整</h4><ReceivingScope :members="change.originalMembers" :transport="context.transport" /><p v-for="(quantity, index) in change.intent.quantities" :key="sourceKey(quantity)">货物 {{ index + 1 }} · 本次先送 {{ quantity.retainedPackages }} 箱</p><p>负责人 {{ change.followup.ownerName || '—' }} · 跟进 {{ change.followup.nextFollowupDate || '待安排' }}</p><p>后送日期 {{ change.intent.laterDate || '未定' }} · {{ change.intent.reason }}</p><p v-for="agreement in change.originalAgreements" :key="agreement.agreementKey">原约号 {{ agreement.details.confirmationNo || '—' }} · {{ agreement.details.appointmentAt || '时段未定' }} · {{ agreement.timezone || '时区待核实' }}</p><label>仓库同意保留量和移出量的回复依据<textarea v-model="evidence" maxlength="1000" aria-label="仓库回复依据" class="workbench-input" /></label></template>
|
||||
<template v-if="group"><h4>{{ mode === 'book' ? '整组重约当前范围' : '整份预约当前范围' }}</h4><ReceivingScope :members="mode === 'book' ? selectedBookMembers : group.agreement.members" :transport="group.transport" /><div v-for="(excluded, index) in group.excludedMembers" :key="`${excluded.member.batchKey}:${excluded.member.customerId}`" role="status">排除范围 {{ index + 1 }} · {{ excluded.member.appointmentNo }}:{{ excluded.reason }}</div></template>
|
||||
<template v-if="mode === 'book'"><p>未取得确认时,可先按客户保存目标或未约成原因。</p><el-button v-for="member in bookMembers.filter(row => row.batchKey === batchKey && planEligible(row.customerId))" :key="member.customerId" :disabled="locked || !edit" @click="openPlan(member.customerId)">{{ member.customerName || member.sources.map(source => source.inboundCode || '—').join('、') }} · 保存目标 / 未约成</el-button><p v-if="!group && cargoIncomplete" role="status">货物数量或来源资料待核实,当前不能登记正式预约。</p><p v-if="bookingScopeChanged" role="status">当前成员范围已变化,请核对上方范围后重新填写预约,原输入不会自动套用给其他客户。<el-button :disabled="locked" @click="resetBookingScope">按当前范围重新填写</el-button></p><template v-if="!group"><p v-for="(excluded, index) in context.executionScope.members.filter(row => row.completed || !row.canRebook)" :key="`${excluded.member.batchKey}:${excluded.member.customerId}`" role="status">排除客户范围 {{ index + 1 }}:{{ excluded.reason || (excluded.completed ? '已完成' : '当前不可重新预约') }}</p></template><ReceivingScope v-if="!group" :members="selectedBookMembers" :transport="context.transport" /><LoadErrorState v-if="scopeError" title="所选客户执行资格读取失败" :message="scopeError.message" :loading="scopeLoading" @retry="refreshSelectedScope" /><p v-if="scopeLoading">正在核对所选客户执行资格…</p><p v-if="!scopeLoading && !scopeError && selectedBookMembers.length && !selectedExecutionAllowed" role="status">所选客户执行资格不完整或不可办理,请核对后重新选择。</p><p v-for="(eligibility, index) in selectedExecution?.members.filter(row => row.completed || !row.canRebook) || []" :key="index" role="status">所选范围 {{ index + 1 }}:{{ eligibility.reason || '当前不可重新预约' }}</p><div aria-label="本次预约客户"><h4>选择本次取得确认的客户</h4><label v-for="(member, index) in bookMembers" :key="memberKey(member)"><input v-model="selectedMemberKeys" @change="refreshSelectedScope" :value="memberKey(member)" type="checkbox" :aria-label="`选择预约客户 ${index + 1}`">{{ member.appointmentNo }} · {{ member.customerName || member.sources.map(source => source.inboundCode || '—').join('、') }}</label><p v-if="!selectedBookMembers.length">请至少选择一个客户。</p></div><label><input v-model="separate" type="checkbox">所选客户分别登记预约</label><p>共同预约须由仓库明确确认覆盖当前全部所选客户和货量;重复填写同一约号不能代替共同预约。</p><template v-for="(row, index) in (bookingScopeChanged ? [] : bookingRows)" :key="index"><h4>{{ separate ? `${row.member?.appointmentNo} · ${row.member?.customerName || '—'} · 客户范围 ${index + 1}` : '一份共同预约' }}</h4><FbaAppointmentFields :model="bookingForms[row.index]!" :timezone="group?.agreement.timezone || context.batch.timezone" :disabled="locked || !edit" /><label>仓库确认覆盖依据<textarea v-model="bookingEvidence[row.index]" maxlength="1000" aria-label="预约覆盖依据" /></label></template></template>
|
||||
<template v-if="mode === 'reschedule' || mode === 'cancel'"><FbaAppointmentFields v-if="mode === 'reschedule'" :model="groupForm" :timezone="group?.agreement.timezone" :disabled="locked || !edit" /><p v-else>整份取消会清除活动确认及失效目标,保留历史、批次数量和车辆关系;后续仍须重新约仓并核对车辆。</p><label>仓库回复依据<textarea v-model="evidence" maxlength="1000" aria-label="整份回复依据" /></label></template>
|
||||
<template v-if="mode === 'book'"><p>未取得确认时,可先按客户保存目标或未约成原因。</p><el-button v-for="member in bookMembers.filter(row => row.batchKey === batchKey && planEligible(row.customerId))" :key="member.customerId" :disabled="locked || !edit" @click="openPlan(member.customerId)">{{ member.customerName || member.sources.map(source => source.inboundCode || '—').join('、') }} · 保存目标 / 未约成</el-button><p v-if="!group && cargoIncomplete" role="status">货物数量或来源资料待核实,当前不能登记正式预约。</p><p v-if="bookingScopeChanged" role="status">当前成员范围已变化,请核对上方范围后重新填写预约,原输入不会自动套用给其他客户。<el-button :disabled="locked" @click="resetBookingScope">按当前范围重新填写</el-button></p><template v-if="!group"><p v-for="(excluded, index) in context.executionScope.members.filter(row => row.completed || !row.canRebook)" :key="`${excluded.member.batchKey}:${excluded.member.customerId}`" role="status">排除客户范围 {{ index + 1 }}:{{ excluded.reason || (excluded.completed ? '已完成' : '当前不可重新预约') }}</p></template><ReceivingScope v-if="!group" :members="selectedBookMembers" :transport="context.transport" /><LoadErrorState v-if="scopeError" title="所选客户执行资格读取失败" :message="scopeError.message" :loading="scopeLoading" @retry="refreshSelectedScope" /><p v-if="scopeLoading">正在核对所选客户执行资格…</p><p v-if="!scopeLoading && !scopeError && selectedBookMembers.length && !selectedExecutionAllowed" role="status">所选客户执行资格不完整或不可办理,请核对后重新选择。</p><p v-for="(eligibility, index) in selectedExecution?.members.filter(row => row.completed || !row.canRebook) || []" :key="index" role="status">所选范围 {{ index + 1 }}:{{ eligibility.reason || '当前不可重新预约' }}</p><div aria-label="本次预约客户"><h4>选择本次取得确认的客户</h4><label v-for="(member, index) in bookMembers" :key="memberKey(member)"><input v-model="selectedMemberKeys" @change="refreshSelectedScope" :value="memberKey(member)" type="checkbox" :aria-label="`选择预约客户 ${index + 1}`">{{ member.appointmentNo }} · {{ member.customerName || member.sources.map(source => source.inboundCode || '—').join('、') }}</label><p v-if="!selectedBookMembers.length">请至少选择一个客户。</p></div><label><input v-model="separate" type="checkbox">所选客户分别登记预约</label><p>共同预约须由仓库明确确认覆盖当前全部所选客户和货量;重复填写同一约号不能代替共同预约。</p><template v-for="(row, index) in (bookingScopeChanged ? [] : bookingRows)" :key="index"><h4>{{ separate ? `${row.member?.appointmentNo} · ${row.member?.customerName || '—'} · 客户范围 ${index + 1}` : '一份共同预约' }}</h4><FbaAppointmentFields :model="bookingForms[row.index]!" :timezone="group?.agreement.timezone || context.batch.timezone" :disabled="locked || !edit" /><label>仓库确认覆盖依据<textarea v-model="bookingEvidence[row.index]" maxlength="1000" aria-label="预约覆盖依据" class="workbench-input" /></label></template></template>
|
||||
<template v-if="mode === 'reschedule' || mode === 'cancel'"><FbaAppointmentFields v-if="mode === 'reschedule'" :model="groupForm" :timezone="group?.agreement.timezone" :disabled="locked || !edit" /><p v-else>整份取消会清除活动确认及失效目标,保留历史、批次数量和车辆关系;后续仍须重新约仓并核对车辆。</p><label>仓库回复依据<textarea v-model="evidence" maxlength="1000" aria-label="整份回复依据" class="workbench-input" /></label></template>
|
||||
<label><input v-model="confirmedScope" type="checkbox" :disabled="mode === 'book' && (scopeLoading || !!scopeError || !selectedExecutionAllowed) || mode === 'refresh' && (cargoLoading || !!cargoError || !!cargoProblems.length)" aria-label="确认当前范围">已核对上述客户货物、当前预约与车辆范围</label>
|
||||
</fieldset>
|
||||
<el-button type="primary" native-type="submit" :disabled="!canSubmit" :loading="saving">{{ pending ? '重试本次提交' : mode === 'refresh' ? '确认采用本客户最新资料' : mode === 'plan' ? '保存本客户计划' : mode === 'customer' ? '保存待仓库确认' : mode === 'resolve' ? '确认回复并应用本次调整' : mode === 'cancel' ? '确认整份取消' : mode === 'reschedule' ? '保存整份改期' : '保存本次正式预约' }}</el-button>
|
||||
<div class="density-actions density-actions--footer appointment-form-actions"><el-button type="primary" native-type="submit" :disabled="!canSubmit" :loading="saving">{{ pending ? '重试本次提交' : mode === 'refresh' ? '确认采用本客户最新资料' : mode === 'plan' ? '保存本客户计划' : mode === 'customer' ? '保存待仓库确认' : mode === 'resolve' ? '确认回复并应用本次调整' : mode === 'cancel' ? '确认整份取消' : mode === 'reschedule' ? '保存整份改期' : '保存本次正式预约' }}</el-button></div>
|
||||
<el-button v-if="mode === 'resolve' && !pending" :disabled="!canSubmit" @click="save('WITHDRAW')">撤回变化并保留原安排</el-button>
|
||||
</form>
|
||||
<LoadErrorState v-if="saveError" title="本次提交尚未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/* Native work-command fields share the existing input theme, with local form layout. */
|
||||
.appointment-native-form {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.appointment-native-form > fieldset {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--admin-space-5);
|
||||
margin: var(--admin-space-3) 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.appointment-native-form > fieldset > .el-button { justify-self: start; }
|
||||
|
||||
.appointment-native-form label:has(> :is(input, select, textarea, .el-date-editor)) {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--admin-space-2);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.appointment-native-form label > :is(.workbench-input, .el-date-editor) {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.appointment-native-form label > textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.appointment-native-form label:has(> input[type='checkbox']) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.appointment-native-form .appointment-form-actions {
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
bottom: 0;
|
||||
padding-block: var(--admin-space-3);
|
||||
border-top: 1px solid var(--workbench-border);
|
||||
background: var(--workbench-surface);
|
||||
}
|
||||
@@ -4,9 +4,23 @@ import { http } from '@/shared/api/http'
|
||||
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: (value: {data: unknown}) => value.data }))
|
||||
const range = { from: '2026-10-01', to: '2026-11-01' }
|
||||
function trip(dispatchNo: string, plannedDepartureAt: string, originTimezone = 'America/Los_Angeles'): TripCalendarEntry {
|
||||
return { sourceType: 'DISPATCH', dispatchNo, status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: 'US warehouse', originCountryCode: 'US', originTimezone, originWarehouseStatus: 'ACTIVE', plannedDepartureAt, expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{name:'US warehouse'}, {name:'FBA'}], incompleteReasons: [] }
|
||||
return { sourceType: 'DISPATCH', dispatchNo, containerNumbers: [], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: 'US warehouse', originCountryCode: 'US', originTimezone, originWarehouseStatus: 'ACTIVE', plannedDepartureAt, expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{name:'US warehouse'}, {name:'FBA'}], incompleteReasons: [] }
|
||||
}
|
||||
describe('location-based trip calendar', () => {
|
||||
it('places the reported UTC plan on October 22 at 09:00 in Los Angeles and exposes every container in one event', () => {
|
||||
const row = { ...trip('PC-CALENDAR-001', '2026-10-22T16:00:00Z'), containerNumbers: ['TSTU1234567', 'TSTU7654321'] }
|
||||
const projected = projectTripCalendar({ records: [row, row], unresolvedRecords: [] }, range)
|
||||
expect(projected.events).toHaveLength(1)
|
||||
expect(projected.events[0]).toMatchObject({ key: 'dispatch-PC-CALENDAR-001', date: '2026-10-22', time: '09:00', sourcePath: '/overseas-warehouse/dispatches/PC-CALENDAR-001' })
|
||||
expect(projected.events[0]!.fields).toContainEqual({ label: '关联柜号', value: 'TSTU1234567、TSTU7654321' })
|
||||
expect(projected.events[0]!.fields).toContainEqual({ label: '计划出发(起运仓当地)', value: '2026-10-22 09:00' })
|
||||
expect(projected.events[0]!.fields).toContainEqual({ label: '计划出发(UTC)', value: '2026-10-22T16:00:00Z' })
|
||||
const beijing = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
|
||||
const parts = Object.fromEntries(beijing.formatToParts(new Date(row.plannedDepartureAt!)).map(part => [part.type, part.value]))
|
||||
expect([parts.year, parts.month, parts.day, parts.hour, parts.minute]).toEqual(['2026', '10', '23', '00', '00'])
|
||||
expect(projectTripCalendar({ records: [row], unresolvedRecords: [] }, { from: '2026-10-22', to: '2026-10-23' }).events).toHaveLength(1)
|
||||
expect(projectTripCalendar({ records: [row], unresolvedRecords: [] }, { from: '2026-10-23', to: '2026-10-24' }).events).toEqual([])
|
||||
})
|
||||
it('routes the canonical dispatch number to dispatch detail and does not invent a link without it', () => {
|
||||
const row = trip('DS/REAL', '2026-10-10T12:00:00Z')
|
||||
const result = projectTripCalendar({ records: [row], unresolvedRecords: [] }, range)
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ScheduleEvent } from './schedule'
|
||||
export interface TripCalendarEntry {
|
||||
sourceType: 'DISPATCH'
|
||||
dispatchNo: string
|
||||
containerNumbers: string[]
|
||||
status: string
|
||||
originWarehouseId: string | null
|
||||
originWarehouseCode: string | null
|
||||
@@ -72,14 +73,17 @@ export function projectTripCalendar(result: TripCalendarResult, range: { from: s
|
||||
}
|
||||
if (occurrence.date < range.from || occurrence.date >= range.to) continue
|
||||
const actual = localStamp(row.departedAt, row.originTimezone)
|
||||
const planned = localStamp(row.plannedDepartureAt, row.originTimezone)
|
||||
const driverEnd = localStamp(row.expectedDriverEndAt, row.originTimezone)
|
||||
events.push({ key: `dispatch-${row.dispatchNo}`, type: 'dispatch', date: occurrence.date, time: occurrence.time,
|
||||
title: `${row.dispatchNo} ${row.departedAt ? '实际发车' : '排车'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '海外仓送往 FBA/客户仓的车辆安排',
|
||||
title: `${row.dispatchNo} ${row.departedAt ? '实际发车' : '排车'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '按起运仓当地计划发车时间展示',
|
||||
sourcePath: `/overseas-warehouse/dispatches/${encodeURIComponent(row.dispatchNo)}`,
|
||||
fields: [
|
||||
{ label: '运输状态', value: labels[row.status] || '待核实' },
|
||||
{ label: '关联柜号', value: row.containerNumbers.join('、') },
|
||||
{ label: '起运仓', value: row.originWarehouseName || row.originWarehouseCode },
|
||||
{ label: '地点时区', value: row.originTimezone },
|
||||
{ label: '计划出发(起运仓当地)', value: planned ? `${planned.date} ${planned.time}` : null },
|
||||
{ label: '计划出发(UTC)', value: row.plannedDepartureAt },
|
||||
{ label: '路线', value: row.stops.slice(0, 3).map(stop => stop.name).join(' → ') + (row.stops.length > 3 ? ` 等${row.stops.length}站` : '') },
|
||||
{ label: '实际出发', value: actual ? `${actual.date} ${actual.time}` : row.departedAt ? '时刻待核实' : '尚未记录' },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { recordClearanceResult, type DeclarationBatch } from './platformContainerCustoms'
|
||||
import { createDeclarationBatch, submitDeclarationBatch, recordClearanceResult, type DeclarationBatch } from './platformContainerCustoms'
|
||||
import { completeAllBuyContainerCustoms, downloadContainerCustomsReceipt, downloadContainerCustomsDocument, getContainerCustoms, sendContainerCustomsToBroker, startContainerCustoms, uploadContainerCustomsReceipt } from './platformContainerCustoms'
|
||||
|
||||
const customs = {
|
||||
@@ -15,6 +15,22 @@ const customs = {
|
||||
describe('platform container customs API', () => {
|
||||
beforeEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('preserves the server batch number when creating and submitting a declaration', async () => {
|
||||
const batch = { id: '123', customsContainerId: '456', loadingPlanId: '789', batchNo: 'DECL202610200001', sourceVersion: 1, version: 0, results: [] }
|
||||
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: batch })
|
||||
const created = await createDeclarationBatch('789', 3)
|
||||
expect(created.batchNo).toBe('DECL202610200001')
|
||||
expect(post.mock.calls[0]?.slice(0, 2)).toEqual([
|
||||
'/platform/v1/customs/loading-plans/789/declaration-batches', { expectedVersion: 3, remark: null },
|
||||
])
|
||||
const submitted = await submitDeclarationBatch(created, ' CUSTOMS-EXTERNAL-1 ')
|
||||
expect(submitted.batchNo).toBe(created.batchNo)
|
||||
expect(post.mock.calls[1]?.slice(0, 2)).toEqual([
|
||||
'/platform/v1/customs/loading-plans/declaration-batches/123/submit',
|
||||
{ expectedVersion: 0, customsEntryNo: 'CUSTOMS-EXTERNAL-1', remark: null },
|
||||
])
|
||||
})
|
||||
|
||||
it('records domestic customs time in Shanghai even when UTC falls on the previous day', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
||||
@@ -178,13 +178,13 @@ export async function getDeclarationBatches(loadingPlanId: string) {
|
||||
return (unwrapApiResponse(response) || []).map(normalizeDeclarationBatch)
|
||||
}
|
||||
|
||||
export async function createDeclarationBatch(loadingPlanId: string, expectedVersion: number, batchNo?: string) {
|
||||
const response = await http.post<ApiEnvelope<DeclarationBatch>, ApiEnvelope<DeclarationBatch>>(`${basePath}/${encodeURIComponent(loadingPlanId)}/declaration-batches`, { expectedVersion, batchNo: batchNo?.trim() || null, remark: null }, { headers: { 'Idempotency-Key': operationId('customs-declaration-batch-create') } })
|
||||
export async function createDeclarationBatch(loadingPlanId: string, expectedVersion: number) {
|
||||
const response = await http.post<ApiEnvelope<DeclarationBatch>, ApiEnvelope<DeclarationBatch>>(`${basePath}/${encodeURIComponent(loadingPlanId)}/declaration-batches`, { expectedVersion, remark: null }, { headers: { 'Idempotency-Key': operationId('customs-declaration-batch-create') } })
|
||||
return normalizeDeclarationBatch(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function submitDeclarationBatch(batch: DeclarationBatch, customsEntryNo: string) {
|
||||
const response = await http.post<ApiEnvelope<DeclarationBatch>, ApiEnvelope<DeclarationBatch>>(`${basePath}/declaration-batches/${encodeURIComponent(batch.id)}/submit`, { expectedVersion: batch.version, batchNo: batch.batchNo, customsEntryNo: customsEntryNo.trim(), remark: null }, { headers: { 'Idempotency-Key': operationId('customs-declaration-batch-submit') } })
|
||||
const response = await http.post<ApiEnvelope<DeclarationBatch>, ApiEnvelope<DeclarationBatch>>(`${basePath}/declaration-batches/${encodeURIComponent(batch.id)}/submit`, { expectedVersion: batch.version, customsEntryNo: customsEntryNo.trim(), remark: null }, { headers: { 'Idempotency-Key': operationId('customs-declaration-batch-submit') } })
|
||||
return normalizeDeclarationBatch(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { listWarehouseLogisticsChannels } from '@/modules/ocean-bookings'
|
||||
import { createContainerSimulation } from '../api/platformContainerLoading'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ContainerSimulationCreate from './ContainerSimulationCreate.vue'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import { h } from 'vue'
|
||||
|
||||
// Node's supported CJS export keeps the real form validator's constructor intact.
|
||||
const ElementPlus = (createRequire(import.meta.url)('element-plus') as typeof import('element-plus')).default
|
||||
@@ -19,14 +21,26 @@ afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
describe('ContainerSimulationCreate opted-in form', () => {
|
||||
it.each([true, false])('preserves required fields and the fixed request in dense=%s mode', async (dense) => {
|
||||
const wrapper = mount(ContainerSimulationCreate, { props: { dense, loadWarehouseOptions: async () => [warehouse] }, global: { plugins: [ElementPlus] }, attachTo: document.body })
|
||||
const host = mount(DataTable, {
|
||||
props: { data: [], columns: [] },
|
||||
slots: { 'toolbar-actions': () => h(ContainerSimulationCreate, { dense, loadWarehouseOptions: async () => [warehouse] }) },
|
||||
global: { plugins: [ElementPlus] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
const wrapper = host.findComponent(ContainerSimulationCreate)
|
||||
expect(wrapper.get('button').element.closest('.data-table__toolbar-actions')).not.toBeNull()
|
||||
await wrapper.findAll('button').find(button => button.text().includes('新增模拟柜'))!.trigger('click')
|
||||
await flushPromises()
|
||||
const dialog = wrapper.findComponent(AppDialog)
|
||||
const dialogRoot = document.body.querySelector<HTMLElement>('.simulation-create-dialog')!
|
||||
expect(dialogRoot).not.toBeNull()
|
||||
expect(dialogRoot.closest('.data-table__toolbar')).toBeNull()
|
||||
expect(dialogRoot.querySelector('.el-dialog__footer button')?.closest('.data-table__toolbar')).toBeNull()
|
||||
expect(dialog.props('width')).toBe('min(800px, calc(100vw - 32px))')
|
||||
dialog.vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
expect(createContainerSimulation).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => expect(dialogRoot.querySelectorAll('.el-form-item__error').length).toBeGreaterThan(0))
|
||||
const fields = dialog.findAllComponents({ name: 'ElFormItem' })
|
||||
expect(fields).toHaveLength(3)
|
||||
fields.find(field => field.props('prop') === 'warehouseId')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '8')
|
||||
@@ -38,5 +52,17 @@ describe('ContainerSimulationCreate opted-in form', () => {
|
||||
await flushPromises()
|
||||
expect(createContainerSimulation).toHaveBeenCalledExactlyOnceWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500 })
|
||||
expect(wrapper.emitted('created')).toHaveLength(1)
|
||||
expect(dialog.props('modelValue')).toBe(false)
|
||||
await wrapper.get('button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
expect(dialogRoot.closest('.data-table__toolbar')).toBeNull()
|
||||
expect(dialog.findAllComponents({ name: 'ElFormItem' }).find(field => field.props('prop') === 'warehouseId')!.findComponent({ name: 'ElSelect' }).props('modelValue')).toBe('')
|
||||
const cancel = Array.from(dialogRoot.querySelectorAll<HTMLButtonElement>('.el-dialog__footer button')).find(button => button.textContent?.trim() === '取消')!
|
||||
cancel.click()
|
||||
await flushPromises()
|
||||
expect(dialog.props('modelValue')).toBe(false)
|
||||
expect(host.find('.data-table__toolbar-actions').exists()).toBe(true)
|
||||
expect(createContainerSimulation).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -101,6 +101,7 @@ async function saveSimulation() {
|
||||
class="simulation-create-dialog"
|
||||
:class="{ 'admin-density-dialog': dense, 'admin-density-dialog--comfortable': dense, 'admin-workbench': dense, 'is-dialog': dense }"
|
||||
title="新增模拟柜"
|
||||
append-to-body
|
||||
width="min(800px, calc(100vw - 32px))"
|
||||
confirm-text="创建模拟柜"
|
||||
cancel-text="取消"
|
||||
|
||||
@@ -304,7 +304,7 @@ describe('ContainersView', () => {
|
||||
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('新增模拟柜'))!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('装柜仓库加载失败')
|
||||
expect(document.body.querySelector('.simulation-create-dialog')?.textContent).toContain('装柜仓库加载失败')
|
||||
})
|
||||
|
||||
it('uses stable view labels when cabinetNo is missing', async () => {
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import ElementPlus, { ElCheckboxButton, ElInputNumber, ElMessage, ElMessageBox, ElOption, ElSelect } from 'element-plus'
|
||||
import { AxiosError } from 'axios'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DOMWrapper, enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import GlobalSmartLoadingView from './GlobalSmartLoadingView.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import SmartLoadingDialog from '../components/SmartLoadingDialog.vue'
|
||||
import type { ContainerBookingResource } from '../types'
|
||||
import {
|
||||
assignPendingReviewSmartLoadingOrder,
|
||||
@@ -27,6 +28,8 @@ import {
|
||||
removeSmartLoadingPallet,
|
||||
} from '../api/platformContainerLoading'
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
const { routerPush } = vi.hoisted(() => ({ routerPush: vi.fn() }))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
@@ -120,7 +123,50 @@ function mockContainerSearch(records: ContainerBookingResource[]) {
|
||||
))))
|
||||
}
|
||||
|
||||
function assignmentDialog() {
|
||||
const root = document.body.querySelector<HTMLElement>('.smart-loading-assignment-dialog')
|
||||
if (!root) throw new Error('assignment dialog is missing')
|
||||
return new DOMWrapper(root)
|
||||
}
|
||||
|
||||
describe('GlobalSmartLoadingView', () => {
|
||||
it('mounts assignment outside the parent workbench and keeps fullscreen and reopen states independent', async () => {
|
||||
const wrapper = mount(SmartLoadingDialog, { attachTo: document.body, props: { modelValue: true }, global: { plugins: [ElementPlus] } })
|
||||
try {
|
||||
await flushPromises()
|
||||
const workbench = wrapper.getComponent(GlobalSmartLoadingView)
|
||||
const openAssignment = async () => {
|
||||
await workbench.get('.candidate-pane .el-table__body-wrapper').findAll('button').find(button => button.text() === '加入')!.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
await openAssignment()
|
||||
const popup = workbench.getComponent(AppDialog)
|
||||
const outer = document.querySelector('.smart-loading-dialog')!
|
||||
const child = assignmentDialog()
|
||||
expect(document.body.contains(child.element)).toBe(true)
|
||||
expect(outer.contains(child.element)).toBe(false)
|
||||
expect(child.element.closest('.global-loading-page')).toBeNull()
|
||||
expect(child.classes()).toEqual(expect.arrayContaining(['admin-density-dialog', 'admin-workbench', 'is-dialog']))
|
||||
popup.getComponent(ElInputNumber).vm.$emit('update:modelValue', 4)
|
||||
await child.get('[aria-label="展开全屏"]').trigger('click')
|
||||
expect(child.classes()).toContain('is-fullscreen')
|
||||
expect(outer.classList.contains('is-fullscreen')).toBe(true)
|
||||
await child.findAll('button').find(button => button.text() === '取消')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(popup.props('modelValue')).toBe(false)
|
||||
expect(wrapper.getComponent(AppDialog).props('modelValue')).toBe(true)
|
||||
expect(workbench.get('.container-option.selected').attributes('aria-pressed')).toBe('true')
|
||||
await openAssignment()
|
||||
expect(popup.props('modelValue')).toBe(true)
|
||||
expect(assignmentDialog().classes()).not.toContain('is-fullscreen')
|
||||
expect(popup.getComponent(ElInputNumber).props('modelValue')).toBe(candidate.availablePackageCount)
|
||||
expect(outer.classList.contains('is-fullscreen')).toBe(true)
|
||||
expect(assignSmartLoadingItem).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['PACKAGE', 'SEALED_PALLET'] as const)('分配窗口全屏保留来源及输入,按%s原接口提交', async (unitType) => {
|
||||
if (unitType === 'SEALED_PALLET') {
|
||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{
|
||||
@@ -138,7 +184,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
await wrapper.get('.candidate-pane .el-table__body-wrapper').findAll('button').find(button => button.text() === '加入')!.trigger('click')
|
||||
await flushPromises()
|
||||
const popup = wrapper.getComponent(AppDialog)
|
||||
expect(popup.get('table[aria-label="全局装柜分配来源"]').text()).toContain('CB-1')
|
||||
expect(assignmentDialog().get('table[aria-label="全局装柜分配来源"]').text()).toContain('CB-1')
|
||||
if (unitType === 'PACKAGE') {
|
||||
const input = popup.getComponent(ElInputNumber)
|
||||
expect(input.props('modelValue')).toBe(candidate.availablePackageCount)
|
||||
@@ -146,11 +192,11 @@ describe('GlobalSmartLoadingView', () => {
|
||||
input.vm.$emit('update:modelValue', 4)
|
||||
} else {
|
||||
expect(popup.findComponent(ElInputNumber).exists()).toBe(false)
|
||||
expect(popup.get('details').text()).toContain('I181:9 箱')
|
||||
expect(assignmentDialog().get('details').text()).toContain('I181:9 箱')
|
||||
expect(popup.props('title')).toBe('整托加入当前柜次 · DP-TEST-1')
|
||||
}
|
||||
await popup.get('[aria-label="展开全屏"]').trigger('click')
|
||||
expect(popup.find('.el-dialog.is-fullscreen').exists()).toBe(true)
|
||||
await assignmentDialog().get('[aria-label="展开全屏"]').trigger('click')
|
||||
expect(assignmentDialog().classes()).toContain('is-fullscreen')
|
||||
if (unitType === 'PACKAGE') expect(popup.getComponent(ElInputNumber).props('modelValue')).toBe(4)
|
||||
popup.vm.$emit('confirm'); await flushPromises()
|
||||
if (unitType === 'PACKAGE') {
|
||||
@@ -572,11 +618,11 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(document.body.textContent).toContain('不能部分输入、单独分配或单独移除')
|
||||
const popup = wrapper.getComponent(AppDialog)
|
||||
expect(popup.props('title')).toBe('整组加入当前柜次 · CLG-2093539779795795969')
|
||||
const contextRows = popup.findAll('table[aria-label="全局装柜分配来源"] tr')
|
||||
const contextRows = assignmentDialog().findAll('table[aria-label="全局装柜分配来源"] tr')
|
||||
expect(contextRows[0]!.findAll('td').map(cell => cell.text())).toEqual(['CB-1', 'MSCU-1'])
|
||||
expect(contextRows[1]!.findAll('td').map(cell => cell.text())).toEqual(['义乌仓', '美森正班'])
|
||||
await popup.get('[aria-label="展开全屏"]').trigger('click')
|
||||
expect(popup.get('table[aria-label="全局装柜同柜组成员"]').text()).toContain('NYEXX2608290051-B')
|
||||
await assignmentDialog().get('[aria-label="展开全屏"]').trigger('click')
|
||||
expect(assignmentDialog().get('table[aria-label="全局装柜同柜组成员"]').text()).toContain('NYEXX2608290051-B')
|
||||
expect(document.body.textContent).not.toContain('本次分配箱数')
|
||||
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click()
|
||||
await flushPromises()
|
||||
@@ -612,9 +658,9 @@ describe('GlobalSmartLoadingView', () => {
|
||||
await flushPromises()
|
||||
const popup = wrapper.getComponent(AppDialog)
|
||||
expect(popup.props('title')).toBe('加入当前柜次 · EXXHK2609020361')
|
||||
const measurements = popup.findAll('table[aria-label="全局装柜分配填写"] tr').find(row => row.text().includes('平均单箱毛重'))!
|
||||
const measurements = assignmentDialog().findAll('table[aria-label="全局装柜分配填写"] tr').find(row => row.text().includes('平均单箱毛重'))!
|
||||
expect(measurements.findAll('td').map(cell => cell.text())).toEqual(['待测量', '待测量'])
|
||||
const channel = popup.findAll('table[aria-label="全局装柜分配来源"] tr').find(row => row.text().includes('订单渠道'))!
|
||||
const channel = assignmentDialog().findAll('table[aria-label="全局装柜分配来源"] tr').find(row => row.text().includes('订单渠道'))!
|
||||
expect(channel.findAll('td')[0]!.text()).toBe('合德海卡')
|
||||
expect(document.body.textContent).toContain('这是人工装柜分配,确认后仍会加入')
|
||||
expect(document.body.textContent).toContain('智能推荐不会跨渠道')
|
||||
@@ -907,10 +953,10 @@ describe('GlobalSmartLoadingView', () => {
|
||||
await flushPromises()
|
||||
|
||||
// 箱数与上方资料使用同一标签列,保留输入名称与原范围。
|
||||
const label = popup.get('label[for="global-loading-assignment-count"]')
|
||||
const label = assignmentDialog().get('label[for="global-loading-assignment-count"]')
|
||||
expect(label.element.parentElement?.tagName).toBe('TH')
|
||||
expect(label.element.parentElement?.nextElementSibling?.getAttribute('colspan')).toBe('3')
|
||||
expect(popup.find('input#global-loading-assignment-count').exists()).toBe(true)
|
||||
expect(assignmentDialog().find('input#global-loading-assignment-count').exists()).toBe(true)
|
||||
expect(input.props('max')).toBe(candidate.availablePackageCount)
|
||||
popup.vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
@@ -918,11 +964,12 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(assignSmartLoadingItem).toHaveBeenCalledWith(plan.id, { inboundId: candidate.inboundId, packageCount: 7, expectedVersion: plan.version })
|
||||
expect(popup.props('modelValue')).toBe(true)
|
||||
expect(input.props('modelValue')).toBe(7)
|
||||
expect(popup.text()).toContain('加入当前柜次失败')
|
||||
expect(popup.text()).toContain('当前账号无权访问该数据')
|
||||
expect(popup.text()).toContain('assign-request')
|
||||
expect(popup.text()).toContain('assign-trace')
|
||||
expect(wrapper.get('.global-loading-page').findAll('.load-error-state')).toHaveLength(1)
|
||||
expect(assignmentDialog().text()).toContain('加入当前柜次失败')
|
||||
expect(assignmentDialog().text()).toContain('当前账号无权访问该数据')
|
||||
expect(assignmentDialog().text()).toContain('assign-request')
|
||||
expect(assignmentDialog().text()).toContain('assign-trace')
|
||||
expect(assignmentDialog().findAll('.load-error-state')).toHaveLength(1)
|
||||
expect(wrapper.get('.global-loading-page').findAll('.load-error-state')).toHaveLength(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
|
||||
@@ -839,7 +839,7 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="assignOpen" :title="assignmentTitle" width="min(920px, calc(100vw - 28px))" fullscreenable :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认加入'" @confirm="assign">
|
||||
<AppDialog class="smart-loading-assignment-dialog admin-density-dialog admin-workbench is-dialog" v-model="assignOpen" :title="assignmentTitle" width="min(920px, calc(100vw - 28px))" append-to-body fullscreenable :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认加入'" @confirm="assign">
|
||||
<LoadErrorState v-if="mutationError" title="加入当前柜次失败" :message="mutationError.message" compact :show-retry="false">
|
||||
<template #details><ApiErrorMeta :details="mutationError" /></template>
|
||||
</LoadErrorState>
|
||||
|
||||
@@ -3,13 +3,15 @@ import { AxiosError } from 'axios'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import SmartLoadingWorkbenchView from './SmartLoadingWorkbenchView.vue'
|
||||
import { assignSmartLoadingGroup, assignSmartLoadingItem, assignSmartLoadingPallet, getContainer, getSmartLoadingPlanByBooking, listSmartLoadingCandidates, recommendSmartLoading, removeSmartLoadingGroup } from '../api/platformContainerLoading'
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
vi.mock('../api/platformContainerLoading', () => ({
|
||||
getContainer: vi.fn(), getSmartLoadingPlanByBooking: vi.fn(), listSmartLoadingCandidates: vi.fn(),
|
||||
createSmartLoadingPlan: vi.fn(), assignSmartLoadingItem: vi.fn(), assignSmartLoadingPallet: vi.fn(), assignSmartLoadingGroup: vi.fn(), recommendSmartLoading: vi.fn(),
|
||||
@@ -233,6 +235,11 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
const dialog = wrapper.getComponent(AppDialog)
|
||||
expect(dialog.props('title')).toBe('按入仓码分配箱数 · IN-1')
|
||||
const count = dialog.getComponent({ name: 'ElInputNumber' })
|
||||
const label = dialog.get('label[for="cabinet-loading-assignment-count"]')
|
||||
expect(label.element.parentElement?.tagName).toBe('TH')
|
||||
expect(label.element.parentElement?.nextElementSibling?.getAttribute('colspan')).toBe('3')
|
||||
expect(dialog.get('input#cabinet-loading-assignment-count').attributes('aria-label')).toBe('本次计划装柜箱数')
|
||||
expect(count.attributes('aria-required')).toBe('true')
|
||||
expect(count.props('max')).toBe(12)
|
||||
count.vm.$emit('update:modelValue', 5)
|
||||
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认分配') as HTMLButtonElement).click()
|
||||
|
||||
@@ -427,7 +427,8 @@ onMounted(load)
|
||||
<tr v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'"><th>整托箱数</th><td colspan="3">整托 {{ selectedCandidate.availablePackageCount }} 箱</td></tr>
|
||||
<template v-else>
|
||||
<tr><th>货物摘要</th><td colspan="3"><LineClampText :content="selectedCandidate.goodsSummary || '货物摘要暂缺'" /></td></tr>
|
||||
<tr><th>可用箱数</th><td>{{ selectedCandidate.availablePackageCount }} 箱</td><td colspan="2"><el-form-item label="本次计划装柜箱数" label-position="left" label-width="140px" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></td></tr>
|
||||
<tr><th>可用箱数</th><td colspan="3">{{ selectedCandidate.availablePackageCount }} 箱</td></tr>
|
||||
<tr><th><label for="cabinet-loading-assignment-count"><span class="assignment-required" aria-hidden="true">*</span>本次计划装柜箱数</label></th><td colspan="3"><el-form-item><el-input-number id="cabinet-loading-assignment-count" v-model="assignForm.packageCount" aria-label="本次计划装柜箱数" aria-required="true" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></td></tr>
|
||||
</template>
|
||||
</tbody></table></div>
|
||||
</el-form>
|
||||
@@ -451,6 +452,9 @@ onMounted(load)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.assignment-table { table-layout: fixed; }
|
||||
.assignment-table th { width: 128px; }
|
||||
.assignment-required { margin-right: var(--admin-space-1); color: var(--el-color-danger); }
|
||||
.workbench { display: grid; gap: var(--admin-space-5); min-width: 0; }
|
||||
.workbench-command-bar,.header-actions,.row-actions,.meter-title { display: flex; align-items: center; }
|
||||
.workbench-command-bar { justify-content: space-between; gap: var(--admin-space-5); }
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
|
||||
/* Customer dialog business geometry; common appearance stays in adminDensity.css. */
|
||||
.customer-editor-dialog .customer-editor-table { table-layout: fixed; }
|
||||
.customer-editor-dialog .customer-editor-table td { width: 50%; }
|
||||
.customer-editor-dialog .customer-editor-table td:not([colspan]) { width: calc(100% / 3); }
|
||||
.customer-review-table th { width: 120px; }
|
||||
.customer-review-documents th:first-child { width: 150px; }
|
||||
.customer-review-documents th:last-child { width: 150px; }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { createRequire } from 'node:module'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
@@ -31,6 +32,8 @@ import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
||||
import CustomersView from './CustomersView.vue'
|
||||
|
||||
const { default: ElementPlus } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
|
||||
|
||||
const customersSource = readFileSync(resolve(process.cwd(), 'src/modules/customers/pages/CustomersView.vue'), 'utf8')
|
||||
|
||||
const { routerPush, routerReplace, routeControl } = vi.hoisted(() => ({
|
||||
@@ -194,6 +197,28 @@ describe('CustomersView', () => {
|
||||
expect(customersSource).not.toContain('<AppDrawer v-model="editorOpen"')
|
||||
})
|
||||
|
||||
it('keeps compact customer fields in three columns and leaves wide fields and validation readable', async () => {
|
||||
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
tableButtonByText('编辑')!.click()
|
||||
await flushPromises()
|
||||
const tables = Array.from(document.querySelectorAll<HTMLTableElement>('.customer-editor-dialog .customer-editor-table'))
|
||||
expect(tables).toHaveLength(2)
|
||||
expect(Array.from(tables[0]!.rows, row => row.cells.length)).toEqual([3, 3, 1, 1])
|
||||
expect(Array.from(tables[1]!.rows, row => row.cells.length)).toEqual([3, 3, 1])
|
||||
expect(tables.flatMap(table => Array.from(table.querySelectorAll('td[colspan]'), cell => cell.getAttribute('colspan')))).toEqual(['3', '3', '3'])
|
||||
expect(document.querySelector<HTMLElement>('.customer-editor-dialog .qualification-credit-code .el-form-item__label')?.style.width).toBe('144px')
|
||||
const company = document.querySelector<HTMLInputElement>('.customer-editor-dialog input[placeholder="请输入工商登记公司名称"]')!
|
||||
expect(company.closest('.el-form-item')?.querySelector<HTMLElement>('.el-form-item__label')?.style.width).toBe('120px')
|
||||
company.value = ''
|
||||
company.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
company.dispatchEvent(new FocusEvent('blur'))
|
||||
await vi.waitFor(() => expect(company.closest('.el-form-item')?.querySelector('.el-form-item__error')?.textContent).toContain('请输入公司名称'))
|
||||
expect(document.querySelector('.customer-editor-dialog .el-dialog__footer')?.textContent).toContain('保存资料和资质')
|
||||
expect(updatePlatformTenant).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps qualification file controls usable when the editor dialog is narrow', () => {
|
||||
expect(customersSource).toContain('class="qualification-validity" role="cell"')
|
||||
expect(customersSource).toContain('grid-template-columns:170px minmax(0,1fr) 220px')
|
||||
|
||||
@@ -1085,28 +1085,26 @@ onMounted(() => { void loadCustomers() })
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog v-model="editorOpen" :title="editorTitle" :width="editorDialogWidth" class="admin-density-dialog customer-density-dialog customer-editor-dialog admin-workbench is-dialog" :loading="editorLoading" :confirm-loading="saving" fullscreenable destroy-on-close>
|
||||
<el-form ref="formRef" v-loading="editorLoading" :model="form" :rules="rules" label-position="left" label-width="144px" class="density-form density-dialog-sections" :disabled="editorLoading || saving || submitting || uploadingQualifications">
|
||||
<el-form ref="formRef" v-loading="editorLoading" :model="form" :rules="rules" label-position="left" label-width="120px" class="density-form density-dialog-sections" :disabled="editorLoading || saving || submitting || uploadingQualifications">
|
||||
<section class="density-dialog-section workbench-evidence" data-tone="work"><h3 class="density-dialog-section__title">基础资料</h3>
|
||||
<table class="density-table density-table--editable customer-editor-table" aria-label="基础资料"><tbody>
|
||||
<tr><td><el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item></td><td><el-form-item label="客户昵称" prop="customerNickname"><el-input v-model="form.customerNickname" placeholder="不填默认生成中文拼音大写首字母,如 SZWL" maxlength="32" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="营业执照企业名称" prop="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item></td><td><el-form-item label="法定代表人" prop="legalRepresentative"><el-input v-model="form.legalRepresentative" placeholder="请输入法定代表人姓名(选填)" maxlength="64" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="企业类型" prop="enterpriseType"><el-input v-model="form.enterpriseType" placeholder="如:有限责任公司" maxlength="64" /></el-form-item></td><td><el-form-item label="成立日期" prop="establishedDate"><el-date-picker style="width: 100%;" v-model="form.establishedDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择成立日期" :disabled-date="(date: Date) => date.getTime() > Date.now()" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="注册资本" prop="registeredCapital"><div class="capital-input"><el-input-number v-model="form.registeredCapital" :min="0" :precision="2" controls-position="right" placeholder="请输入注册资本(选填)" /><el-select v-model="form.registeredCapitalCurrency" aria-label="注册资本币种"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /><el-option label="港币 HKD" value="HKD" /></el-select></div></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="注册地址" prop="registeredAddress"><el-input v-model="form.registeredAddress" placeholder="请输入省市区、街道及门牌信息" maxlength="500" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item></td><td><el-form-item label="客户昵称" prop="customerNickname"><el-input v-model="form.customerNickname" placeholder="不填默认生成中文拼音大写首字母,如 SZWL" maxlength="32" /></el-form-item></td><td><el-form-item label="营业执照企业名称" prop="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="法定代表人" prop="legalRepresentative"><el-input v-model="form.legalRepresentative" placeholder="请输入法定代表人姓名(选填)" maxlength="64" /></el-form-item></td><td><el-form-item label="企业类型" prop="enterpriseType"><el-input v-model="form.enterpriseType" placeholder="如:有限责任公司" maxlength="64" /></el-form-item></td><td><el-form-item label="成立日期" prop="establishedDate"><el-date-picker style="width: 100%;" v-model="form.establishedDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择成立日期" :disabled-date="(date: Date) => date.getTime() > Date.now()" /></el-form-item></td></tr>
|
||||
<tr><td colspan="3"><el-form-item label="注册资本" prop="registeredCapital"><div class="capital-input"><el-input-number v-model="form.registeredCapital" :min="0" :precision="2" controls-position="right" placeholder="请输入注册资本(选填)" /><el-select v-model="form.registeredCapitalCurrency" aria-label="注册资本币种"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /><el-option label="港币 HKD" value="HKD" /></el-select></div></el-form-item></td></tr>
|
||||
<tr><td colspan="3"><el-form-item label="注册地址" prop="registeredAddress"><el-input v-model="form.registeredAddress" placeholder="请输入省市区、街道及门牌信息" maxlength="500" /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
|
||||
<section class="density-dialog-section workbench-evidence" data-tone="work"><h3 class="density-dialog-section__title">联系与商务<InlineHelp title="企业联系信息说明"><p>仅用于企业业务联系,不会修改企业管理员登录手机号;登录手机号请在成员/账号管理中单独维护,修改会影响登录。</p></InlineHelp></h3>
|
||||
<table class="density-table density-table--editable customer-editor-table" aria-label="联系与商务"><tbody>
|
||||
<tr><td><el-form-item label="联系人" prop="contactName" required><el-input v-model="form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></el-form-item></td><td><el-form-item label="公司联系电话" prop="contactPhone" required><el-input v-model="form.contactPhone" placeholder="请输入公司联系电话" maxlength="32" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" placeholder="如:contact@example.com" maxlength="128" /></el-form-item></td><td><el-form-item label="联系微信" prop="contactWechat"><el-input v-model="form.contactWechat" placeholder="请输入联系人微信号" maxlength="64" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="销售人员" prop="salesOwnerName"><el-input v-model="form.salesOwnerName" placeholder="请输入负责销售人员" maxlength="64" /></el-form-item></td><td><el-form-item label="账期(天)" prop="paymentTermDays" required><el-input-number v-model="form.paymentTermDays" :min="0" :max="365" controls-position="right" placeholder="请输入账期天数" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="内部备注" prop="internalRemark"><el-input v-model="form.internalRemark" type="textarea" :rows="3" placeholder="请输入仅平台人员可见的客户备注" maxlength="1000" show-word-limit /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="联系人" prop="contactName" required><el-input v-model="form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></el-form-item></td><td><el-form-item label="公司联系电话" prop="contactPhone" required><el-input v-model="form.contactPhone" placeholder="请输入公司联系电话" maxlength="32" /></el-form-item></td><td><el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" placeholder="如:contact@example.com" maxlength="128" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="联系微信" prop="contactWechat"><el-input v-model="form.contactWechat" placeholder="请输入联系人微信号" maxlength="64" /></el-form-item></td><td><el-form-item label="销售人员" prop="salesOwnerName"><el-input v-model="form.salesOwnerName" placeholder="请输入负责销售人员" maxlength="64" /></el-form-item></td><td><el-form-item label="账期(天)" prop="paymentTermDays" required><el-input-number v-model="form.paymentTermDays" :min="0" :max="365" controls-position="right" placeholder="请输入账期天数" /></el-form-item></td></tr>
|
||||
<tr><td colspan="3"><el-form-item label="内部备注" prop="internalRemark"><el-input v-model="form.internalRemark" type="textarea" :rows="3" placeholder="请输入仅平台人员可见的客户备注" maxlength="1000" show-word-limit /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
|
||||
<section class="density-dialog-section qualification-section workbench-evidence" data-tone="work"><h3 class="density-dialog-section__title">企业认证资料<InlineHelp title="资质提交规则"><p>营业执照和18位统一社会信用代码为必填业务项;法人身份证、货代资质证书和银行开户许可证均为选填。</p><p>支持 PDF、JPG、PNG,单个文件不超过 5MB;<span>*</span> 为提交审核必填项。</p></InlineHelp></h3><div v-if="editorStatus" class="qualification-heading"><el-tag :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></div>
|
||||
<table class="density-table density-table--editable" aria-label="企业信用代码"><tbody><tr><td><el-form-item label="统一社会信用代码" prop="unifiedSocialCreditCode" required class="qualification-credit-code"><span v-if="creditCodeEstablished">{{ form.unifiedSocialCreditCode }}</span><el-input v-else v-model="form.unifiedSocialCreditCode" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /><small>{{ creditCodeEstablished ? '已登记的统一社会信用代码不可直接修改。' : '必须与营业执照一致,并随营业执照共同保存;法人身份证为选填。' }}</small></el-form-item></td></tr></tbody></table>
|
||||
<table class="density-table density-table--editable" aria-label="企业信用代码"><tbody><tr><td><el-form-item label="统一社会信用代码" prop="unifiedSocialCreditCode" label-width="144px" required class="qualification-credit-code"><span v-if="creditCodeEstablished">{{ form.unifiedSocialCreditCode }}</span><el-input v-else v-model="form.unifiedSocialCreditCode" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /><small>{{ creditCodeEstablished ? '已登记的统一社会信用代码不可直接修改。' : '必须与营业执照一致,并随营业执照共同保存;法人身份证为选填。' }}</small></el-form-item></td></tr></tbody></table>
|
||||
<div v-if="qualificationLoadError" class="qualification-load-error" role="alert">{{ qualificationLoadError }}<el-button v-if="editingId" link type="primary" @click="loadEditorQualifications(editingId)">重试</el-button></div>
|
||||
<div class="qualification-table" role="table" aria-label="客户资质文件">
|
||||
<div class="qualification-row qualification-header" role="row"><span role="columnheader">文件类型</span><span role="columnheader">上传文件</span><span role="columnheader">有效期</span></div>
|
||||
|
||||
@@ -427,8 +427,9 @@ onMounted(() => {
|
||||
.issue-counts { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
|
||||
.issue-counts .is-blocking, .is-danger { color: var(--el-color-danger); font-weight: 700; }
|
||||
.visually-hidden-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; clip-path: inset(50%); }
|
||||
.upload-file-control { display: flex; align-items: center; gap: var(--admin-space-5); }
|
||||
.upload-file-name { min-width: 0; overflow-wrap: anywhere; }
|
||||
.upload-file-control { display: flex; width: 100%; min-width: 0; align-items: center; flex-wrap: wrap; gap: var(--admin-space-5); }
|
||||
.upload-file-control > :deep(.el-button) { flex: 0 0 auto; }
|
||||
.upload-file-name { min-width: 0; flex: 1 1 180px; overflow-wrap: anywhere; }
|
||||
@media (max-width: 720px) {
|
||||
.table-actions :deep(.el-button) { min-height: 40px; }
|
||||
.upload-file-control { align-items: stretch; flex-direction: column; }
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('InboundMeasurementDialog', () => {
|
||||
expect(source).toContain('class="inbound-measurement-workspace-dialog admin-density-dialog admin-density-workspace inbound-density admin-workbench is-dialog"')
|
||||
expect(source).toContain('width="min(1560px, calc(100vw - 48px))"')
|
||||
expect(source).toContain('height: 92vh')
|
||||
expect(densitySource).toContain('.admin-density-dialog.el-dialog .el-dialog__header')
|
||||
expect(densitySource).toContain('.admin-density-dialog.el-dialog > .el-dialog__header')
|
||||
expect(densitySource).toContain('padding: var(--admin-space-4, 10px) 48px var(--admin-space-4, 10px) var(--admin-space-6, 16px)')
|
||||
expect(source).toContain('<WeightOcrPanel')
|
||||
expect(source).toContain('<InboundMeasurementPanel')
|
||||
|
||||
@@ -143,19 +143,19 @@ function handleSubmitted() {
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.inbound-measurement-workspace-dialog.el-dialog { display: flex; max-width: calc(100vw - 32px); height: 92vh; max-height: 92vh; margin-bottom: 0; flex-direction: column; overflow: hidden; }
|
||||
.inbound-measurement-workspace-dialog .el-dialog__body { flex: 1 1 auto; }
|
||||
.inbound-measurement-workspace-dialog.el-dialog:not(.is-fullscreen) { display: flex; max-width: calc(100vw - 32px); height: 92vh; max-height: 92vh; margin-bottom: 0; flex-direction: column; overflow: hidden; }
|
||||
.inbound-measurement-workspace-dialog > .el-dialog__body { flex: 1 1 auto; }
|
||||
.inbound-measurement-workspace-dialog .measurement-panel { border: 0; border-radius: 0; box-shadow: none; }
|
||||
.inbound-measurement-workspace-dialog .measurement-panel__heading .measurement-panel__actions,
|
||||
.inbound-measurement-workspace-dialog .measurement-panel__footer { display: none; }
|
||||
@media (max-width: 720px) {
|
||||
.inbound-measurement-workspace-dialog.el-dialog { width: calc(100vw - 20px) !important; max-width: calc(100vw - 20px); }
|
||||
.inbound-measurement-workspace-dialog .el-dialog__header { padding: 16px; }
|
||||
.inbound-measurement-workspace-dialog .el-dialog__body { padding: 14px; }
|
||||
.inbound-measurement-workspace-dialog.el-dialog:not(.is-fullscreen) { width: calc(100vw - 20px) !important; max-width: calc(100vw - 20px); }
|
||||
.inbound-measurement-workspace-dialog > .el-dialog__header { padding: 16px; }
|
||||
.inbound-measurement-workspace-dialog > .el-dialog__body { padding: 14px; }
|
||||
}
|
||||
@media (max-width: 360px) {
|
||||
.inbound-measurement-workspace-dialog.el-dialog { width: calc(100vw - 12px) !important; max-width: calc(100vw - 12px); }
|
||||
.inbound-measurement-workspace-dialog .el-dialog__header { padding: 14px 12px; }
|
||||
.inbound-measurement-workspace-dialog .el-dialog__body { padding: 10px; }
|
||||
.inbound-measurement-workspace-dialog.el-dialog:not(.is-fullscreen) { width: calc(100vw - 12px) !important; max-width: calc(100vw - 12px); }
|
||||
.inbound-measurement-workspace-dialog > .el-dialog__header { padding: 14px 12px; }
|
||||
.inbound-measurement-workspace-dialog > .el-dialog__body { padding: 10px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '../api/weightOcr'
|
||||
import type { WeightResultResource, WeightWorkspaceResource } from '../types'
|
||||
import WeightOcrPanel from './WeightOcrPanel.vue'
|
||||
import InboundMeasurementDialog from '../../measurement/InboundMeasurementDialog.vue'
|
||||
|
||||
vi.mock('../api/weightOcr', async () => {
|
||||
const actual = await vi.importActual<typeof import('../api/weightOcr')>('../api/weightOcr')
|
||||
@@ -151,6 +152,46 @@ function dialogConfirmButton(text = '确认重量') {
|
||||
}
|
||||
|
||||
describe('WeightOcrPanel confirmation behavior', () => {
|
||||
it('keeps the weight confirmation outside its measurement parent with independent fullscreen and cancel state', async () => {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'token'
|
||||
auth.permissions = allWeightPermissions
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
const wrapper = mount(InboundMeasurementDialog, {
|
||||
attachTo: document.body,
|
||||
props: { modelValue: true, customerId: '10001', inboundId: '20001', inboundCode: 'RK-20001' },
|
||||
global: {
|
||||
plugins: [pinia, ElementPlus],
|
||||
stubs: { InboundMeasurementPanel: { template: '<section class="measurement-panel" />' } },
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
const panel = wrapper.getComponent(WeightOcrPanel)
|
||||
await openWeightDialog(panel)
|
||||
const popup = panel.getComponent(AppDialog)
|
||||
const parent = document.querySelector('.inbound-measurement-workspace-dialog')!
|
||||
const child = document.querySelector('input[aria-label="当前图片确认重量"]')!.closest('.el-dialog')!
|
||||
expect(document.body.contains(child)).toBe(true)
|
||||
expect(parent.contains(child)).toBe(false)
|
||||
expect(child.classList.contains('admin-workbench')).toBe(true)
|
||||
await dialogWeightInput().setValue('286.125')
|
||||
await new DOMWrapper(child).get('button[aria-label="展开全屏"]').trigger('click')
|
||||
expect(child.classList.contains('is-fullscreen')).toBe(true)
|
||||
expect(parent.classList.contains('is-fullscreen')).toBe(false)
|
||||
expect(dialogWeightInput().element.value).toBe('286.125')
|
||||
await new DOMWrapper(child).findAll('button').find(button => button.text() === '取消')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(popup.props('modelValue')).toBe(false)
|
||||
expect(wrapper.getComponent(AppDialog).props('modelValue')).toBe(true)
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
await openWeightDialog(panel)
|
||||
expect(popup.props('modelValue')).toBe(true)
|
||||
expect(child.classList.contains('is-fullscreen')).toBe(false)
|
||||
expect(confirmWeightResult).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
|
||||
@@ -658,6 +658,7 @@ defineExpose({ reload: loadWorkspace })
|
||||
:confirm-text="previewConfirmText"
|
||||
confirm-type="success"
|
||||
width="min(1080px, calc(100vw - 28px))"
|
||||
append-to-body
|
||||
fullscreenable
|
||||
@confirm="previewResult && confirmResult(previewResult, true)"
|
||||
>
|
||||
|
||||
@@ -127,6 +127,18 @@ describe('notification native dialogs and drawers', () => {
|
||||
await click(wrapper, '确认定时发送')
|
||||
expect(api.createNotification).toHaveBeenCalledWith(expect.objectContaining({ tenantId: '9223372036854775100', recipientType: 'TENANT_ALL', variables: { order_no: 'NYEXX2610200001' }, scheduledAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/) }))
|
||||
})
|
||||
it('keeps long template variable labels above their editable values', async () => {
|
||||
const variable = 'customer_delivery_appointment_reference_number'
|
||||
const wrapper = await composer([variable])
|
||||
const field = wrapper.get('.message-variable-field')
|
||||
expect(field.classes()).toContain('el-form-item--label-top')
|
||||
expect(field.get('label').text()).toContain(variable)
|
||||
expect((field.get('label').element as HTMLElement).style.width).not.toBe('100px')
|
||||
await field.get('input[placeholder="请输入变量值"]').setValue('REFERENCE-20261021')
|
||||
await wrapper.get('input[placeholder="请输入服务端角色编码"]').setValue('TEST_OPERATOR')
|
||||
await click(wrapper, '立即发送')
|
||||
expect(api.createNotification).toHaveBeenCalledWith(expect.objectContaining({ variables: { [variable]: 'REFERENCE-20261021' } }))
|
||||
})
|
||||
it('locks composer close paths and retains the draft after a rejected send', async () => {
|
||||
const pending = deferred<NotificationTask>()
|
||||
vi.mocked(api.createNotification).mockReturnValue(pending.promise)
|
||||
|
||||
@@ -82,7 +82,7 @@ async function submit() {
|
||||
</section>
|
||||
<section v-if="selectedTemplate?.variableWhitelist.length" class="density-dialog-section workbench-evidence" data-tone="work" aria-label="模板变量">
|
||||
<h3 class="density-dialog-section__title">模板变量</h3>
|
||||
<table class="density-table density-table--editable"><tbody><tr v-for="key in selectedTemplate.variableWhitelist" :key="key"><td><el-form-item :label="key" required><el-input v-model="variables[key]" maxlength="2000" placeholder="请输入变量值" /></el-form-item></td></tr></tbody></table>
|
||||
<table class="density-table density-table--editable"><tbody><tr v-for="key in selectedTemplate.variableWhitelist" :key="key"><td><el-form-item :label="key" label-position="top" label-width="auto" class="message-variable-field" required><el-input v-model="variables[key]" maxlength="2000" placeholder="请输入变量值" /></el-form-item></td></tr></tbody></table>
|
||||
</section>
|
||||
</el-form>
|
||||
<template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button :disabled="submitting" @click="close">取消</el-button><el-button type="primary" :loading="submitting" @click="submit">{{ scheduled ? '确认定时发送' : '立即发送' }}</el-button></div></template>
|
||||
@@ -90,6 +90,7 @@ async function submit() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-variable-field :deep(.el-form-item__label) { max-width: 100%; overflow-wrap: anywhere; }
|
||||
.schedule-row { display: grid; width: 100%; grid-template-columns: auto auto minmax(180px, 1fr); align-items: center; gap: var(--admin-space-3); }
|
||||
.schedule-row :deep(.el-date-editor) { width: 100%; }
|
||||
@media(max-width:560px) { .schedule-row { grid-template-columns: auto 1fr; } .schedule-row :deep(.el-date-editor) { grid-column: 1 / -1; } }
|
||||
|
||||
@@ -104,3 +104,12 @@
|
||||
2026-10-20 独立样式审查收尾:采购详情删除局部.purchase-detail的旧border/radius/background,外框由workbench-panel统一维护,业务布局保留。4个页面文件的模板/样式编译及公共外观归属检查通过,相关4文件58项回归通过,差异检查通过;人工截图仍待确认。
|
||||
|
||||
2026-10-20 用户追加整页白底修正:整页主要列表外层workbench-table及整页详情workbench-panel显式增加is-page-surface,由公共样式移除最外圈border/radius/shadow。内部指标、图表、并列卡片、子表格线、弹框和日历左右独立面板保留;本批不改padding、脚本或承载。
|
||||
|
||||
|
||||
### 2026-10-21 采购节点弹框可用性修复
|
||||
|
||||
| 页面/文件 | 操作与既有承载 | 结构问题与修复 | 验收 |
|
||||
|---|---|---|---|
|
||||
| ProcurementNodeActions | 采购详情中的节点命令居中弹框 | 自定义footer绕过AppDialog默认操作区容器;三项操作加入density-actions底部容器,允许换行并保持间距 | 定向回归通过;待人工1920×1080、100%截图确认 |
|
||||
|
||||
保持既有命令、草稿、未知结果核对、权限与提交条件;只新增操作区容器。公共AppDialog的直接按钮footer修复由公共样式写者维护,本域未修改公共组件。
|
||||
|
||||
@@ -235,7 +235,7 @@ async function upload(event: Event) {
|
||||
<el-form-item v-if="needsAmount || needsEvidence" label="本单凭证" required><el-select v-model="form.voucherFileId" placeholder="选择已上传凭证"><el-option v-for="file in files" :key="file.fileId" :value="file.fileId" :label="file.fileName" /></el-select><input aria-label="上传本单凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
|
||||
<el-form-item label="操作说明" required><el-input v-model="form.reason" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
|
||||
</el-form><el-alert v-if="error" :title="error.message" type="error" :closable="false" /><ApiErrorMeta v-if="error" :details="error" /></template>
|
||||
<template #footer><el-button :disabled="saving || uploading" @click="opened = false">关闭</el-button><el-button v-if="editingDraft" :disabled="!!pending || !!storageError || saving || uploading" @click="useCorrectionDraft">核对并继续确认</el-button><el-button type="primary" :loading="saving" :disabled="uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" @click="submit">{{ editingDraft ? '保存本地草稿' : pending ? '重试原请求' : '确认提交' }}</el-button></template>
|
||||
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="saving || uploading" @click="opened = false">关闭</el-button><el-button v-if="editingDraft" :disabled="!!pending || !!storageError || saving || uploading" @click="useCorrectionDraft">核对并继续确认</el-button><el-button type="primary" :loading="saving" :disabled="uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" @click="submit">{{ editingDraft ? '保存本地草稿' : pending ? '重试原请求' : '确认提交' }}</el-button></div></template>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -329,3 +329,12 @@ S05补充源码核对:实际自有/外配API均经公共解包检查业务码
|
||||
2026-10-20 独立样式审查收尾:同类复核删除出车统计面板的旧padding/background/border/radius,以及自有/外配汇总的重复background/border;仅保留网格与字段布局。4个页面文件的模板/样式编译及公共外观归属检查通过,相关4文件58项回归通过,差异检查通过;人工截图仍待确认。
|
||||
|
||||
2026-10-20 用户追加整页白底修正:整页主要列表外层workbench-table及整页详情workbench-panel显式增加is-page-surface,由公共样式移除最外圈border/radius/shadow。内部指标、图表、并列卡片、子表格线、弹框和日历左右独立面板保留;本批不改padding、脚本或承载。散板需求“我的登记”通过CapacityInquiryTable单根透传class,仅列表调用去整页外圈;详情中的同组件保留内部边框。
|
||||
|
||||
|
||||
### 2026-10-21 运营登记弹框可用性修复
|
||||
|
||||
| 页面/文件 | 操作与既有承载 | 结构问题与修复 | 验收 |
|
||||
|---|---|---|---|
|
||||
| OwnedBusinessOperationsEditor、OutsourcedBusinessOperationsEditor | 自有/外配业务详情的居中运营登记弹框 | 表单补齐公共密度控件宽度;目的仓及预约号允许换行,实际到达时间列以受约束宽度占位,防止长业务编号撑宽 | 定向回归通过;待人工1920×1080、100%截图确认 |
|
||||
|
||||
依据本轮可用性反馈与本文件原操作矩阵;金额、日期时区、版本及未知结果重试逻辑均未修改。司机、车辆和运维编辑表单已有多列布局与公共底部确认入口,源码复查未据此扩大修改;真实可用性仍需截图确认。
|
||||
|
||||
@@ -80,13 +80,17 @@ async function discardRejected() {
|
||||
<template>
|
||||
<OutsourcedBusinessOperationsDialog v-model="open" :dispatch-no="detail?.row.dispatchNo" :busy="busy" :pending="Boolean(pending)" :save-disabled="!writable" :before-close="beforeClose" @save="save">
|
||||
<template #state><LoadErrorState v-if="error" title="运营登记未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="pending">原请求已保留,字段锁定;核对结果前不会建立新请求。</p><el-button v-if="pending?.rejected" :disabled="busy" @click="discardRejected">按明确拒绝结果重新编辑</el-button><p v-if="!writable">{{ detail?.row.actions.edit.blockReason || '没有运营登记权限' }}</p></template>
|
||||
<template #amount><el-form label-position="top" class="outsourced-business-form"><el-form-item label="币种"><el-select v-model="form.currency" clearable :disabled="locked"><el-option label="CNY" value="CNY" /><el-option label="USD" value="USD" /></el-select></el-form-item><el-form-item label="整车承运金额"><el-input v-model="form.amount" :disabled="locked" placeholder="未登记;允许明确填写0" /></el-form-item><el-form-item label="更正说明"><el-input v-model="form.reason" :disabled="locked" maxlength="1000" /></el-form-item></el-form></template>
|
||||
<template #amount><el-form label-position="top" class="density-form outsourced-business-form"><el-form-item label="币种"><el-select v-model="form.currency" clearable :disabled="locked"><el-option label="CNY" value="CNY" /><el-option label="USD" value="USD" /></el-select></el-form-item><el-form-item label="整车承运金额"><el-input v-model="form.amount" :disabled="locked" placeholder="未登记;允许明确填写0" /></el-form-item><el-form-item label="更正说明"><el-input v-model="form.reason" :disabled="locked" maxlength="1000" /></el-form-item></el-form></template>
|
||||
<template #arrivals><div v-for="destination in detail?.row.destinations || []" :key="destination.destinationKey" class="outsourced-business-arrival"><span>{{ destination.warehouseCode || destination.warehouseName || destination.address || '目的地资料缺失' }}<small v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ appointment.appointmentNo }}</small></span><el-date-picker v-model="form.arrivals[destination.destinationKey]" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" :disabled="locked" placeholder="实际到达时间,未登记留空" /></div><p>时间按浏览器本地时区输入,提交时保留时区。</p></template>
|
||||
</OutsourcedBusinessOperationsDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.outsourced-business-form { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 0 var(--admin-space-4); }
|
||||
.outsourced-business-arrival { display: grid; grid-template-columns: minmax(0,1fr) 280px; align-items: center; gap: var(--admin-space-4); margin-bottom: var(--admin-space-3); }
|
||||
.outsourced-business-arrival { display: grid; grid-template-columns: minmax(0,1fr) minmax(240px,320px); align-items: center; gap: var(--admin-space-4); margin-bottom: var(--admin-space-3); }
|
||||
.outsourced-business-arrival small { display: block; }
|
||||
|
||||
.outsourced-business-form :deep(.el-form-item) { min-width: 0; }
|
||||
.outsourced-business-form :deep(.el-select), .outsourced-business-arrival :deep(.el-date-editor) { width: 100%; min-width: 0; }
|
||||
.outsourced-business-arrival > span { min-width: 0; overflow-wrap: anywhere; }
|
||||
</style>
|
||||
|
||||
@@ -80,14 +80,18 @@ async function discardRejected() {
|
||||
<template>
|
||||
<OwnedBusinessOperationsDialog v-model="open" :dispatch-no="detail?.row.dispatchNo" :busy="busy" :pending="Boolean(pending)" :save-disabled="!writable" :before-close="beforeClose" @save="save">
|
||||
<template #state><LoadErrorState v-if="error" title="运营登记未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="pending">原请求已保留,字段锁定;核对结果前不会建立新请求。</p><el-button v-if="pending?.rejected" :disabled="busy" @click="discardRejected">按明确拒绝结果重新编辑</el-button><p v-if="!writable">{{ detail?.row.actions.edit.blockReason || '没有运营登记权限' }}</p></template>
|
||||
<template #amount><el-form label-position="top" class="owned-business-form"><el-form-item label="币种"><el-select v-model="form.currency" clearable :disabled="locked"><el-option label="CNY" value="CNY" /><el-option label="USD" value="USD" /></el-select></el-form-item><el-form-item label="整车总金额"><el-input v-model="form.amount" :disabled="locked" placeholder="未登记;允许明确填写0" /></el-form-item><el-form-item label="更正说明"><el-input v-model="form.reason" :disabled="locked" maxlength="1000" /></el-form-item></el-form></template>
|
||||
<template #amount><el-form label-position="top" class="density-form owned-business-form"><el-form-item label="币种"><el-select v-model="form.currency" clearable :disabled="locked"><el-option label="CNY" value="CNY" /><el-option label="USD" value="USD" /></el-select></el-form-item><el-form-item label="整车总金额"><el-input v-model="form.amount" :disabled="locked" placeholder="未登记;允许明确填写0" /></el-form-item><el-form-item label="更正说明"><el-input v-model="form.reason" :disabled="locked" maxlength="1000" /></el-form-item></el-form></template>
|
||||
<template #arrivals><div v-for="destination in detail?.row.destinations || []" :key="destination.destinationKey" class="owned-business-arrival"><span>{{ destination.warehouseCode || destination.warehouseName || destination.address || '目的地资料缺失' }}<small v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ appointment.appointmentNo }}</small></span><el-date-picker v-model="form.arrivals[destination.destinationKey]" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" :disabled="locked" placeholder="实际到达时间,未登记留空" /></div><p>时间按浏览器本地时区输入,提交时保留时区。</p></template>
|
||||
<template #measurements><el-form label-position="top" class="owned-business-form"><el-form-item label="实际里程(mile)"><el-input v-model="form.actualDistanceMiles" :disabled="locked" placeholder="未登记" /></el-form-item><el-form-item label="油耗(L/100km)"><el-input v-model="form.fuelConsumptionLitresPer100Km" :disabled="locked" placeholder="手填耗油率,未登记留空" /></el-form-item></el-form></template>
|
||||
<template #measurements><el-form label-position="top" class="density-form owned-business-form"><el-form-item label="实际里程(mile)"><el-input v-model="form.actualDistanceMiles" :disabled="locked" placeholder="未登记" /></el-form-item><el-form-item label="油耗(L/100km)"><el-input v-model="form.fuelConsumptionLitresPer100Km" :disabled="locked" placeholder="手填耗油率,未登记留空" /></el-form-item></el-form></template>
|
||||
</OwnedBusinessOperationsDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.owned-business-form { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 0 var(--admin-space-4); }
|
||||
.owned-business-arrival { display: grid; grid-template-columns: minmax(0,1fr) 280px; align-items: center; gap: var(--admin-space-4); margin-bottom: var(--admin-space-3); }
|
||||
.owned-business-arrival { display: grid; grid-template-columns: minmax(0,1fr) minmax(240px,320px); align-items: center; gap: var(--admin-space-4); margin-bottom: var(--admin-space-3); }
|
||||
.owned-business-arrival small { display: block; }
|
||||
|
||||
.owned-business-form :deep(.el-form-item) { min-width: 0; }
|
||||
.owned-business-form :deep(.el-select), .owned-business-arrival :deep(.el-date-editor) { width: 100%; min-width: 0; }
|
||||
.owned-business-arrival > span { min-width: 0; overflow-wrap: anywhere; }
|
||||
</style>
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
|
||||
本次仅调整列表顶部插槽位置与公共类,不改变详情、新增、编辑、审核、文件预览或取消等操作承载,不修改业务脚本、API 或权限。
|
||||
|
||||
## 2026-10-20 候选服务端分页
|
||||
|
||||
| 页面/操作 | 已确认承载与依据 | 本批实现 | 验收 |
|
||||
|---|---|---|---|
|
||||
| 智能排车/选货分组 | 本文“智能排车在途预排与操作整理”矩阵;现行排车契约第17项的服务端分页;`SmartDispatchDialog.vue` 居中弹框与候选/装车组布局 | 候选表下显示总数、页码和10/20/50条选择;筛选与区域页签交给服务端,变化后回第一页;草稿保留跨页完整组 | 自动检查单独记录;1920×1080、100%待人工截图确认 |
|
||||
| 既有排车/加货 | `DispatchWorkflowDialog.vue` 的既有1120px居中工作流弹框;同一候选分页契约 | 候选表下分页并显示已选完整组数;跨页保留选择,提交继续检查组版本和批次原始版本 | 自动检查单独记录;1920×1080、100%待人工截图确认 |
|
||||
|
||||
## 2026-10-20 智能排车紧凑布局复核
|
||||
|
||||
### 后续截图:标签、标题与底部操作区对齐
|
||||
@@ -240,3 +247,15 @@
|
||||
2026-10-20 用户追加整页白底修正:整页主要列表外层workbench-table及整页详情workbench-panel显式增加is-page-surface,由公共样式移除最外圈border/radius/shadow。内部指标、图表、并列卡片、子表格线、弹框和日历左右独立面板保留;本批不改padding、脚本或承载。layoutAcceptance.test.ts仅将旧页头分隔线颜色期望改为当前workbench-border,托盘先于预约及字段列数等布局断言保留。
|
||||
|
||||
本次整页面层收口:40个Vue文件仅新增class modifier,逐文件内容对照与差异检查通过;layoutAcceptance.test.ts定向6项通过。文件已固定,公共样式及全量门禁由总控收口,人工视觉仍待截图确认。
|
||||
|
||||
|
||||
### 2026-10-21 附件预览与尾程列表核对
|
||||
|
||||
| 页面/文件 | 操作与既有承载 | 结构问题与修复 | 验收 |
|
||||
|---|---|---|---|
|
||||
| DispatchEvidenceFiles | DispatchWorkflowDialog内附件预览居中二级弹框 | 原预览挂载在父弹框DOM内;显式append-to-body,隔离父级布局与滚动容器 | 定向回归通过;待人工1920×1080、100%截图确认 |
|
||||
| LastMileOrdersView | 尾程订单筛选区 | 用户要求隐藏仓储转运与退货两段长汇总;本批确认两段模板及专用样式未恢复,保留行数据仍使用的统计响应 | 定向回归通过;待人工截图确认 |
|
||||
|
||||
本批未修改SmartDispatchDialog及其候选分页、入库计划、换标与退货出库业务逻辑。源码复查这些入口的既有承载与表单/表格布局,未凭推测批量改宽度或删除必要横向滚动。
|
||||
|
||||
公共缺口复核:此前AppDialog后代选择器可能连带命中二级弹框;当前公共写者已将header/body/footer改为直接子级选择器,代码层已收口,本域未修改公共样式;真实嵌套预览仍待人工截图确认。
|
||||
|
||||
@@ -65,10 +65,10 @@ describe('native whole-vehicle dispatch API', () => {
|
||||
expect(vi.mocked(http.get).mock.calls.map(call => call[0])).toEqual(['/platform/v1/last-leg/dispatches/PC%2F1', '/platform/v1/last-leg/dispatches/PC%2F1/loading', '/platform/v1/last-leg/dispatches/PC%2F1/commands/key%2F1'])
|
||||
})
|
||||
it('reads candidates with their exact sourceVersion and repeated plan query parameters', async () => {
|
||||
const data = { candidates: [candidate()], snapshotAt: '2026-10-19T10:00:00Z', complete: true }
|
||||
const data = { total: 1, page: 1, size: 20, pages: 1, candidates: [candidate()], snapshotAt: '2026-10-19T10:00:00Z', complete: true }
|
||||
vi.mocked(http.get).mockResolvedValueOnce({ data })
|
||||
expect(await api.listDispatchCandidates({ warehouseId: '321', planIds: ['11', '12'] })).toEqual(data)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', { params: { warehouseId: '321', planIds: ['11', '12'] }, paramsSerializer: { indexes: null } })
|
||||
expect(await api.listDispatchCandidates({ warehouseId: '321', planIds: ['11', '12'], page: 2, size: 20, warehouseTab: 'NYC' })).toEqual(data)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', { params: { warehouseId: '321', planIds: ['11', '12'], page: 2, size: 20, warehouseTab: 'NYC' }, paramsSerializer: { indexes: null } })
|
||||
})
|
||||
it('creates an empty real dispatch without an artificial customer or cargo', async () => {
|
||||
const request: CreateDispatch = { idempotencyKey: 'create', warehouseId: '321', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [], batchAllocations: [] }
|
||||
|
||||
@@ -80,7 +80,7 @@ onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) UR
|
||||
<table v-if="ids.length" class="density-table dispatch-evidence-table"><thead><tr><th class="dispatch-evidence__actions">操作</th><th>附件名称</th></tr></thead><tbody><tr v-for="id in ids" :key="id"><td class="dispatch-evidence__actions"><div class="density-actions"><el-button link :disabled="!metadata[id] || previewLoading" @click="preview(id)">预览</el-button><template v-if="!readonly"><el-button link :disabled="disabled || uploading" @click="choose(id)">替换</el-button><el-button link :disabled="disabled || uploading" @click="remove(id)">移除引用</el-button></template></div></td><td><LineClampText :content="metadata[id]?.fileName || '附件资料待核实'" /></td></tr></tbody></table>
|
||||
<p v-else class="density-dialog-note">暂无本趟附件。</p>
|
||||
<template v-if="!readonly"><input ref="input" class="evidence-input" type="file" accept="application/pdf,image/jpeg,image/png" :aria-label="'上传' + label" :disabled="disabled || uploading || !dispatchNo" @change="upload"><el-button :disabled="disabled || uploading || !dispatchNo" :loading="uploading" @click="choose()">上传{{ label }}</el-button></template>
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" :model-value="imageOpen" :title="imageName || '附件预览'" width="min(900px, calc(100vw - 28px))" fullscreenable :loading="previewLoading" :show-confirm="false" cancel-text="关闭" @update:model-value="closePreview">
|
||||
<AppDialog append-to-body class="admin-density-dialog admin-workbench is-dialog" :model-value="imageOpen" :title="imageName || '附件预览'" width="min(900px, calc(100vw - 28px))" fullscreenable :loading="previewLoading" :show-confirm="false" cancel-text="关闭" @update:model-value="closePreview">
|
||||
<div class="density-dialog-sections"><section class="density-dialog-section" data-tone="source" aria-label="本车附件预览信息"><h3 class="density-dialog-section__title">附件信息</h3><table class="density-table"><tbody><tr><th scope="row">排车单号</th><td>{{ dispatchNo || '—' }}</td></tr><tr><th scope="row">附件名称</th><td>{{ imageName || '—' }}</td></tr></tbody></table></section><p v-if="previewLoading" class="density-dialog-note" role="status">正在读取本车附件…</p><img v-if="imageUrl" class="evidence-image" :src="imageUrl" :alt="imageName"></div>
|
||||
</AppDialog>
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { DOMWrapper, enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import DispatchWorkflowDialog from './DispatchWorkflowDialog.vue'
|
||||
import DispatchArrangeFields from './DispatchArrangeFields.vue'
|
||||
@@ -54,7 +54,7 @@ beforeEach(() => {
|
||||
vi.mocked(api.getDispatchRoute).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, edit: gate, stops: [{ index: 0, name: '起运仓', address: '起点地址', destinationKey: null }, { index: 1, name: '目的仓甲', address: '甲地址', destinationKey: 'A' }, { index: 2, name: '外接卸货点', address: '乙地址', destinationKey: 'B' }] })
|
||||
vi.mocked(api.getDispatchItems).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: '9007199254740993', status: 'ACTIVE', stage: 'BEFORE_LOADING', items: [item('9223372036854775001'), item('picked', 1)] })
|
||||
vi.mocked(api.listDispatches).mockResolvedValue(page())
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [sharedPalletCandidates()[0]!], complete: true, snapshotAt: '' })
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 1, page: 1, size: 20, pages: 1, candidates: [sharedPalletCandidates()[0]!], complete: true, snapshotAt: '' })
|
||||
vi.mocked(api.getDispatchArrangement).mockResolvedValue(arrangement())
|
||||
vi.mocked(api.getDispatchEvidence).mockImplementation(async (_, __, id) => evidence(id))
|
||||
vi.mocked(api.getDispatchEvidenceContent).mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
|
||||
@@ -190,9 +190,11 @@ describe('dispatch workflow native layout', () => {
|
||||
await files.findAll('button').find(button => button.text() === '预览')!.trigger('click'); await flushPromises()
|
||||
const image = files.findComponent(AppDialog)
|
||||
expect(image.props('modelValue')).toBe(true)
|
||||
expect(image.get('img').attributes('src')).toBe('blob:owned-dispatch')
|
||||
await image.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||
await image.findAll('button').find(button => button.text() === '关闭')!.trigger('click'); await flushPromises()
|
||||
const preview = new DOMWrapper(document.querySelector('[aria-label="本车附件预览信息"]')!.closest('.el-dialog')!)
|
||||
expect(preview.get('img').attributes('src')).toBe('blob:owned-dispatch')
|
||||
await preview.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||
expect(preview.classes()).toContain('is-fullscreen')
|
||||
await preview.findAll('button').find(button => button.text() === '关闭')!.trigger('click'); await flushPromises()
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:owned-dispatch')
|
||||
const input = files.get('input[type="file"]'), file = new File(['png'], 'new.png', { type: 'image/png' })
|
||||
Object.defineProperty(input.element, 'files', { configurable: true, value: [file] }); await input.trigger('change'); await flushPromises()
|
||||
|
||||
@@ -16,7 +16,7 @@ vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchItems: vi
|
||||
interface State { submit(): Promise<void>; retryError(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: string }; detail: DispatchDetail }
|
||||
it('adds all hidden shared-pallet sources once and rejects a changed group composition before another command', async () => {
|
||||
const [first, second] = sharedPalletCandidates()
|
||||
vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [first!], complete: true, snapshotAt: '' })
|
||||
vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 1, page: 1, size: 20, pages: 1, candidates: [first!], complete: true, snapshotAt: '' })
|
||||
const { wrapper, state } = await open('ADD_CARGO')
|
||||
const selection = wrapper.vm as unknown as { selectCargo(row: NonNullable<typeof first>, checked: boolean): void }
|
||||
selection.selectCargo(first!, true)
|
||||
@@ -25,6 +25,7 @@ it('adds all hidden shared-pallet sources once and rejects a changed group compo
|
||||
first!.selectionGroup.complete = false; selection.selectCargo(first!, false)
|
||||
await state.submit(); expect(api.addDispatchCargo).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('请选择来源完整')
|
||||
first!.selectionGroup.complete = true
|
||||
await (wrapper.vm as unknown as { searchCargo(): Promise<void> }).searchCargo()
|
||||
selection.selectCargo(first!, true)
|
||||
vi.mocked(api.addDispatchCargo).mockImplementation(async (_, request) => command('ADD_CARGO', request.idempotencyKey)); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 }))
|
||||
await state.submit(); expect(api.addDispatchCargo).toHaveBeenCalledExactlyOnceWith('PC/1', expect.objectContaining({ cargo: [first!.selection, second!.selection] }))
|
||||
@@ -32,7 +33,7 @@ it('adds all hidden shared-pallet sources once and rejects a changed group compo
|
||||
})
|
||||
it.each([0, '0', '9007199254740993'])('adds cargo with original batch version %s while keeping the parent dispatch version independent', async recordVersion => {
|
||||
const value = candidate({ appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion, sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2 }] }] })
|
||||
vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [value], complete: true, snapshotAt: '' })
|
||||
vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 1, page: 1, size: 20, pages: 1, candidates: [value], complete: true, snapshotAt: '' })
|
||||
const { wrapper, state } = await open('ADD_CARGO')
|
||||
try {
|
||||
(wrapper.vm as unknown as { selectCargo(row: typeof value, checked: boolean): void }).selectCargo(value, true)
|
||||
@@ -43,7 +44,7 @@ it.each([0, '0', '9007199254740993'])('adds cargo with original batch version %s
|
||||
})
|
||||
it('blocks cargo addition when the authoritative batch version is absent', async () => {
|
||||
const value = candidate({ appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion: undefined as unknown as number, sources: [] }] })
|
||||
vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [value], complete: true, snapshotAt: '' })
|
||||
vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 1, page: 1, size: 20, pages: 1, candidates: [value], complete: true, snapshotAt: '' })
|
||||
const { wrapper, state } = await open('ADD_CARGO')
|
||||
try {
|
||||
(wrapper.vm as unknown as { selectCargo(row: typeof value, checked: boolean): void }).selectCargo(value, true); await state.submit()
|
||||
@@ -234,3 +235,30 @@ it('describes a proven success with a failed GET as completed and retries only i
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('preserves selected complete groups across pages and submits hidden sources with their original versions', async () => {
|
||||
const [first, hidden] = sharedPalletCandidates(), second = candidate({ sourceKey: 'next-page' })
|
||||
vi.mocked(api.listDispatches).mockResolvedValue(page())
|
||||
vi.mocked(api.listDispatchCandidates).mockImplementation(async query => ({ candidates: query.page === 2 ? [second] : [first!], page: query.page!, size: query.size!, total: 21, pages: 2, complete: false, snapshotAt: '' }))
|
||||
const { wrapper, state } = await open('ADD_CARGO')
|
||||
try {
|
||||
const selection = wrapper.vm as unknown as { selectCargo(row: typeof second, checked: boolean): void; changeCandidatePage(value: number): void; candidatePageNumber: number; candidateKeyword: string; searchCargo(): Promise<void> }
|
||||
selection.selectCargo(first!, true); selection.changeCandidatePage(2); await flushPromises(); selection.selectCargo(second, true); await flushPromises()
|
||||
expect(wrapper.text()).toContain('已选择 2 个完整来源组')
|
||||
selection.candidateKeyword = 'next'; await selection.searchCargo()
|
||||
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, orderNo: 'next' }))
|
||||
expect(api.listDispatches).toHaveBeenCalledTimes(1)
|
||||
vi.mocked(api.addDispatchCargo).mockImplementation(async (_, request) => command('ADD_CARGO', request.idempotencyKey)); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 }))
|
||||
await state.submit()
|
||||
expect(api.addDispatchCargo).toHaveBeenCalledWith('PC/1', expect.objectContaining({ cargo: [first!.selection, hidden!.selection, second.selection] }))
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
it('shows authoritative warehouse lookup failures when retrying a cargo search', async () => {
|
||||
vi.mocked(api.listDispatches).mockRejectedValue(new Error('warehouse lookup unavailable'))
|
||||
const { wrapper } = await open('ADD_CARGO')
|
||||
try {
|
||||
await (wrapper.vm as unknown as { searchCargo(): Promise<void> }).searchCargo()
|
||||
expect(wrapper.text()).toContain('货物候选读取失败')
|
||||
expect(api.listDispatchCandidates).not.toHaveBeenCalled()
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import LineClampText from '@/shared/components/LineClampText.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import * as api from '../api/dispatches'
|
||||
import { useDispatchCandidates } from '../useDispatchCandidates'
|
||||
import type { AddCargo, ArrangeDelivery, ArrangeGroup, ArrangementPreview, Candidate, CommandResult, DispatchAction, DispatchDetail, Driver, LoadingComplete, LoadingPreview, ReasonCommand, ReviewCommand, UpdateDispatch, Vehicle, VersionCommand } from '../dispatchContracts'
|
||||
import type { DispatchItems, DispatchRoute, DispatchRouteCommand, DispatchRouteStop } from '../dispatchContracts'
|
||||
import { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, selectionGroupBlockReason } from '../dispatchContracts'
|
||||
@@ -30,11 +31,16 @@ const currentAction = computed(() => pending.value?.action || props.action)
|
||||
const currentNo = computed(() => pending.value?.dispatchNo || props.dispatchNo)
|
||||
const currentOperation = computed(() => pending.value ? pending.value.operation : props.operation)
|
||||
const items = ref<DispatchItems | null>(null), itemId = ref(''), route = ref<DispatchRoute | null>(null), stops = ref<DispatchRouteStop[]>([])
|
||||
const detail = ref<DispatchDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const detail = ref<DispatchDetail | null>(null), working = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const factsStale = ref(false)
|
||||
const preview = ref<LoadingPreview | null>(null), arrangement = ref<ArrangementPreview | null>(null), groups = ref<ArrangeGroup[]>([])
|
||||
const arrangeFields = ref<InstanceType<typeof DispatchArrangeFields> | null>(null)
|
||||
const candidates = ref<Candidate[]>([]), selected = ref<Array<{ sourceKey: string; groupKey: string; groupVersion: string }>>([]), candidateKeyword = ref('')
|
||||
const candidatePage = useDispatchCandidates(error)
|
||||
const { candidates, page: candidatePageNumber, size: candidatePageSize, total: candidateTotal } = candidatePage
|
||||
const loading = computed(() => working.value || candidatePage.loading.value)
|
||||
const selected = ref<Array<{ sourceKey: string; groupKey: string; groupVersion: string; row: Candidate }>>([]), candidateKeyword = ref('')
|
||||
const candidateWarehouseId = ref('')
|
||||
let candidateFilterKey = ''
|
||||
const measurements = ref<Array<{ warehousePalletId: string; palletNo: string; palletSpaces: number | null; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null }>>([])
|
||||
const blankVehicle = (): Vehicle => ({ mode: 'MASTER', vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] })
|
||||
const blankDriver = (): Driver => ({ mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } })
|
||||
@@ -50,9 +56,10 @@ const uploadingEvidence = computed(() => evidenceBusy.VEHICLE || evidenceBusy.DR
|
||||
|
||||
async function load() {
|
||||
const sequence = ++generation, no = currentNo.value, action = currentAction.value
|
||||
if (editorRequestKey && pending.value?.body.idempotencyKey === editorRequestKey && detail.value?.dispatchNo === no) { loading.value = false; return }
|
||||
loading.value = true; factsStale.value = false; error.value = null; detail.value = null; preview.value = null; arrangement.value = null; selected.value = []
|
||||
if (editorRequestKey && pending.value?.body.idempotencyKey === editorRequestKey && detail.value?.dispatchNo === no) { working.value = false; return }
|
||||
working.value = true; factsStale.value = false; error.value = null; detail.value = null; preview.value = null; arrangement.value = null; selected.value = []
|
||||
items.value = null; itemId.value = ''; route.value = null; stops.value = []
|
||||
candidatePage.reset(); candidateWarehouseId.value = ''
|
||||
try {
|
||||
if (action === 'CREATE') throw new DispatchInputError('请从排车列表的原请求入口恢复新建排车')
|
||||
const value = await api.getDispatch(no)
|
||||
@@ -97,20 +104,32 @@ async function load() {
|
||||
}
|
||||
if (action === 'ADD_CARGO') await loadCandidates(sequence)
|
||||
} catch (cause) { if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车操作资料读取失败') }
|
||||
finally { if (sequence === generation) loading.value = false }
|
||||
finally { if (sequence === generation) working.value = false }
|
||||
}
|
||||
async function loadCandidates(sequence = generation) {
|
||||
// Detail has no warehouse field: resolve the authoritative list row by this business number.
|
||||
const page = await api.listDispatches({ page: 1, size: 1, dispatchNo: currentNo.value, status: 'ACTIVE' })
|
||||
const row = page.records.find(item => item.dispatchNo === currentNo.value)
|
||||
if (!row?.warehouseId) throw new DispatchInputError('当前排车起运仓未返回,请刷新核对')
|
||||
const result = await api.listDispatchCandidates({ warehouseId: row.warehouseId, orderNo: candidateKeyword.value || undefined })
|
||||
if (!candidateWarehouseId.value) {
|
||||
// Detail has no warehouse field: resolve the authoritative list row by this business number.
|
||||
const no = currentNo.value
|
||||
const result = await api.listDispatches({ page: 1, size: 1, dispatchNo: no, status: 'ACTIVE' })
|
||||
if (sequence !== generation) return
|
||||
const row = result.records.find(item => item.dispatchNo === no)
|
||||
if (!row?.warehouseId) throw new DispatchInputError('当前排车起运仓未返回,请刷新核对')
|
||||
candidateWarehouseId.value = row.warehouseId
|
||||
}
|
||||
if (sequence !== generation) return
|
||||
if (result.complete !== true) throw new DispatchInputError('候选来源未完整返回,请缩小范围重新查询')
|
||||
if (result.candidates.some(source => source.warehouseId !== row.warehouseId || !source.sourceVersion || source.selection.sourceVersion !== source.sourceVersion)) throw new DispatchInputError('货物起运仓或来源版本不完整,请重新读取')
|
||||
candidates.value = result.candidates
|
||||
const filterKey = JSON.stringify([candidateWarehouseId.value, candidateKeyword.value])
|
||||
if (filterKey !== candidateFilterKey) { candidatePageNumber.value = 1; candidateFilterKey = filterKey }
|
||||
await candidatePage.load({ warehouseId: candidateWarehouseId.value, orderNo: candidateKeyword.value || undefined, page: candidatePageNumber.value, size: candidatePageSize.value, warehouseTab: 'ALL' })
|
||||
}
|
||||
async function searchCargo() { if (frozen.value) return; loading.value = true; error.value = null; try { await loadCandidates() } catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '货物候选读取失败') } finally { loading.value = false } }
|
||||
async function searchCargo() {
|
||||
if (frozen.value) return
|
||||
const sequence = generation
|
||||
candidatePageNumber.value = 1
|
||||
try { await loadCandidates(sequence) }
|
||||
catch (cause) { if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '货物候选读取失败') }
|
||||
}
|
||||
function changeCandidatePage(value: number) { candidatePageNumber.value = value; void loadCandidates() }
|
||||
function changeCandidateSize(value: number) { candidatePageSize.value = value; void searchCargo() }
|
||||
|
||||
function requirePlanPallet(row: Candidate): string {
|
||||
if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对')
|
||||
@@ -122,7 +141,7 @@ function selectCargo(row: Candidate, checked: unknown) {
|
||||
if (checked !== true) { selected.value = selected.value.filter(value => value.groupKey !== row.selectionGroup?.groupKey && value.sourceKey !== row.sourceKey); return }
|
||||
const group = requireSelectionGroup(row)
|
||||
selected.value = selected.value.filter(value => value.groupKey !== group.groupKey && value.sourceKey !== row.sourceKey)
|
||||
if (checked === true) selected.value.push({ sourceKey: row.sourceKey, groupKey: group.groupKey, groupVersion: group.groupVersion })
|
||||
if (checked === true) selected.value.push({ sourceKey: row.sourceKey, groupKey: group.groupKey, groupVersion: group.groupVersion, row })
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '完整选货组无效') }
|
||||
}
|
||||
function payload(): object {
|
||||
@@ -142,12 +161,13 @@ function payload(): object {
|
||||
case 'EDIT': return { ...common, vehicle: vehicle.value, driver: driver.value, plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
|
||||
case 'ADD_CARGO': {
|
||||
const chosen = selected.value.map(value => {
|
||||
const row = candidates.value.find(candidate => candidate.sourceKey === value.sourceKey)
|
||||
const row = value.row
|
||||
if (!row || row.selectionGroup?.groupKey !== value.groupKey || row.selectionGroup.groupVersion !== value.groupVersion) throw new DispatchInputError('选货组组成已变化,请重新读取并选择货物')
|
||||
return row
|
||||
})
|
||||
candidatePage.assertCurrent(chosen)
|
||||
requireBatchVersions(chosen.flatMap(row => row.appointmentBatches))
|
||||
if (!chosen.length || chosen.some(row => row.blockReasons.length || !row.sourceVersion || row.sourceVersion !== row.selection.sourceVersion || row.appointmentBatches.some(batch => batch.customerId !== row.customerId))) throw new DispatchInputError('请选择来源完整、预约客户一致且可排的货物')
|
||||
if (!chosen.length || chosen.some(row => row.warehouseId !== candidateWarehouseId.value || row.blockReasons.length || !row.sourceVersion || row.sourceVersion !== row.selection.sourceVersion || row.appointmentBatches.some(batch => batch.customerId !== row.customerId))) throw new DispatchInputError('请选择来源完整、预约客户一致且可排的货物')
|
||||
return { ...common, cargo: expandSelectionGroups(chosen), batchAllocations: chosen.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => ({ planPalletId: requirePlanPallet(row), batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion })))) } satisfies AddCargo
|
||||
}
|
||||
case 'DISPATCH_REVIEW': case 'LOADING_REVIEW':
|
||||
@@ -221,7 +241,7 @@ async function submit() {
|
||||
}
|
||||
async function refreshFacts() {
|
||||
const sequence = ++generation, no = currentNo.value
|
||||
loading.value = true; factsStale.value = true
|
||||
working.value = true; factsStale.value = true
|
||||
try {
|
||||
const value = await api.getDispatch(no)
|
||||
if (sequence !== generation) return
|
||||
@@ -238,11 +258,11 @@ async function refreshFacts() {
|
||||
} catch (cause) {
|
||||
// Retain the original rejection and every editor value if its follow-up read also fails.
|
||||
if (sequence === generation && !error.value) error.value = getApiErrorDetails(cause, '当前操作资格读取失败,请重试刷新')
|
||||
} finally { if (sequence === generation) loading.value = false }
|
||||
} finally { if (sequence === generation) working.value = false }
|
||||
}
|
||||
async function retryError() { if (pending.value) await submit(); else if (detail.value) await refreshFacts(); else await load() }
|
||||
function moveStop(index: number, offset: number) { const target = index + offset; if (frozen.value || target < 0 || target >= stops.value.length) return; [stops.value[index], stops.value[target]] = [stops.value[target]!, stops.value[index]!] }
|
||||
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else generation++ }, { immediate: true })
|
||||
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else { generation++; candidatePage.reset() } }, { immediate: true })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :loading="loading" :confirm-loading="busy || uploadingEvidence" :confirm-disabled="loading || uploadingEvidence || !pending && !allowed" :confirm-text="pending?.result ? '刷新页面资料' : pending ? '核对并重试原请求' : '确认提交'" @confirm="submit">
|
||||
@@ -273,6 +293,8 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
<section v-else-if="currentAction === 'ADD_CARGO'" class="density-dialog-section" data-tone="source" aria-label="本车添加货物"><h3 class="density-dialog-section__title">选择完整货物组</h3>
|
||||
<div class="workflow-cargo-query"><el-input v-model="candidateKeyword" :disabled="frozen || loading || uploadingEvidence || !allowed" placeholder="订单号/入仓码" /><el-button :disabled="frozen || loading || uploadingEvidence || !allowed" @click="searchCargo">查询</el-button></div>
|
||||
<el-table :data="candidates" border><el-table-column label="选择" width="70" fixed="left"><template #default="{ row }"><el-checkbox :model-value="isCargoSelected(row as Candidate)" @change="selectCargo(row as Candidate, $event)" :disabled="frozen || loading || uploadingEvidence || !allowed || (!isCargoSelected(row as Candidate) && Boolean(selectionGroupBlockReason(row as Candidate)))" :aria-label="'选择货物 ' + (row.palletNo || row.orderNo || '待核实')" /></template></el-table-column><el-table-column prop="customerName" label="客户" min-width="140" show-overflow-tooltip /><el-table-column prop="orderNo" label="订单号" min-width="150" show-overflow-tooltip /><el-table-column label="托盘与完整来源组" min-width="240"><template #default="{ row }"><LineClampText :content="row.palletNo || '—'" /><details class="workflow-source-group"><summary>查看完整来源</summary><ul><li v-for="source in row.selectionGroup.sources" :key="source.sourceKey">{{ source.sourceBusinessNo || '—' }} · {{ source.customerName }} · {{ source.packages }}箱</li></ul></details><span>{{ selectionGroupBlockReason(row as Candidate) }}</span></template></el-table-column><el-table-column prop="availablePackages" label="可排箱数" width="100" /><el-table-column label="目的仓" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.destination.warehouseCode || row.destination.address || '—' }}</template></el-table-column></el-table>
|
||||
<el-pagination aria-label="加货候选分页" :current-page="candidatePageNumber" :page-size="candidatePageSize" :page-sizes="[10, 20, 50]" :total="candidateTotal" :disabled="frozen || loading || !allowed" layout="total, sizes, prev, pager, next" @update:current-page="changeCandidatePage" @update:page-size="changeCandidateSize" />
|
||||
<p class="density-dialog-note">已选择 {{ selected.length }} 个完整来源组(含其他页)</p>
|
||||
</section>
|
||||
<template v-else-if="currentAction === 'LOADING_COMPLETE' && preview">
|
||||
<el-alert :title="preview.loadingComplete.blockReason || 'PDA真实拣货已核平,请核对每托实际装载。'" type="info" :closable="false" show-icon />
|
||||
|
||||
@@ -37,7 +37,7 @@ beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([warehouse])
|
||||
vi.mocked(api.listDispatchDrafts).mockResolvedValue([draft()])
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: draft().sources, complete: true, snapshotAt: '' })
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 1, page: 1, size: 20, pages: 1, candidates: draft().sources, complete: true, snapshotAt: '' })
|
||||
vi.mocked(api.createDispatch).mockImplementation(async body => command('CREATE', body.idempotencyKey))
|
||||
vi.mocked(api.convertDispatchDraft).mockImplementation(async body => ({ draftToken: body.draftToken, replayed: false, vehicles: body.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, `PC/${index + 1}`) })) }))
|
||||
vi.mocked(api.getDispatch).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 }))
|
||||
@@ -69,7 +69,7 @@ describe('new dispatch native layout', () => {
|
||||
const planned = candidate({ selection: { ...candidate().selection, warehousePalletId: null } })
|
||||
vi.mocked(api.createDispatchDraft).mockResolvedValue(empty)
|
||||
vi.mocked(api.createDispatchDraftContainer).mockResolvedValue(grouped)
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [planned], complete: true, snapshotAt: '' })
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 1, page: 1, size: 20, pages: 1, candidates: [planned], complete: true, snapshotAt: '' })
|
||||
vi.mocked(api.lockDispatchDraftPallet).mockResolvedValue({ ...grouped, recordVersion: 2, sources: [planned], containers: [{ ...grouped.containers[0]!, sourceKeys: [planned.sourceKey] }] })
|
||||
const { dialog } = await render()
|
||||
const start = () => view(dialog).findAll('button').find(button => button.text() === '开始选货')!
|
||||
@@ -192,16 +192,16 @@ describe('new dispatch native layout', () => {
|
||||
expect(vi.mocked(api.convertDispatchDraft).mock.calls[1]![0]).toEqual(original)
|
||||
})
|
||||
it('keeps all candidate rows and complete source details in the body with shared ellipsis components', async () => {
|
||||
const rows = Array.from({ length: 25 }, (_, index) => candidate({ sourceKey: `SOURCE-${index}`, palletNo: `PALLET-${index}`, orderNo: `ORDER-${index}`, customerName: '很长的客户名称'.repeat(8) }))
|
||||
const rows = Array.from({ length: 20 }, (_, index) => candidate({ sourceKey: `SOURCE-${index}`, palletNo: `PALLET-${index}`, orderNo: `ORDER-${index}`, customerName: '很长的客户名称'.repeat(8) }))
|
||||
rows[0] = sharedPalletCandidates()[0]!
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: rows, complete: true, snapshotAt: '' })
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 20, page: 1, size: 20, pages: 1, candidates: rows, complete: true, snapshotAt: '' })
|
||||
const { dialog } = await render(); await selectWarehouse(dialog)
|
||||
const table = view(dialog).get('[aria-label="排车候选货物"]')
|
||||
expect(table.findAll('.el-table__body-wrapper .el-table__row')).toHaveLength(25)
|
||||
expect(table.text()).toContain('PALLET-24')
|
||||
expect(table.findAll('.el-table__body-wrapper .el-table__row')).toHaveLength(20)
|
||||
expect(table.text()).toContain('PALLET-19')
|
||||
expect(dialog.findComponent({ name: 'ElTable' }).props('height')).toBeUndefined()
|
||||
expect(dialog.findComponent({ name: 'ElTable' }).props('maxHeight')).toBeUndefined()
|
||||
expect(dialog.findAllComponents(LineClampText).length).toBeGreaterThanOrEqual(50)
|
||||
expect(dialog.findAllComponents(LineClampText).length).toBeGreaterThanOrEqual(40)
|
||||
const sourceGroup = table.findAll('details')[0]!
|
||||
await sourceGroup.get('summary').trigger('click'); await flushPromises()
|
||||
expect(sourceGroup.element.hasAttribute('open')).toBe(true)
|
||||
@@ -216,11 +216,12 @@ describe('new dispatch native layout', () => {
|
||||
await view(dialog).get('input[placeholder="目的仓码"]').setValue('ONT8')
|
||||
dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '全部来源')!.vm.$emit('update:modelValue', sourceStage); await flushPromises()
|
||||
await view(dialog).findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises()
|
||||
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith({ warehouseId: '321', orderNo: 'IN-QUERY', destinationWarehouseCode: 'ONT8', sourceStage, draftToken: undefined })
|
||||
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith({ page: 1, size: 20, warehouseTab: 'ALL', warehouseId: '321', orderNo: 'IN-QUERY', destinationWarehouseCode: 'ONT8', sourceStage, draftToken: undefined })
|
||||
const calls = vi.mocked(api.listDispatchCandidates).mock.calls.length
|
||||
await view(dialog).get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.trigger('click'); await flushPromises()
|
||||
expect(view(dialog).get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.attributes('aria-selected')).toBe('true')
|
||||
expect(api.listDispatchCandidates).toHaveBeenCalledTimes(calls)
|
||||
expect(api.listDispatchCandidates).toHaveBeenCalledTimes(calls + 1)
|
||||
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, warehouseTab: 'NYC', orderNo: 'IN-QUERY', sourceStage }))
|
||||
})
|
||||
it('shows loading and initialization diagnostics without issuing create commands', async () => {
|
||||
vi.mocked(api.listDispatchDrafts).mockRejectedValueOnce(new ApiResponseError('草稿加载失败', 'A0503', 503, null, 'draft-load-request')).mockResolvedValueOnce([draft()])
|
||||
|
||||
@@ -20,7 +20,7 @@ const source = () => candidate(), draft = (): DispatchDraft => ({ draftToken: 's
|
||||
it('converts a filtered relabel group with both authoritative sources and locks it through one HTTP command', async () => {
|
||||
const [first, second] = sharedPalletCandidates(), original = draft()
|
||||
original.sources = [first!]; original.containers[0]!.sourceKeys = [first!.sourceKey]
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [first!], complete: true, snapshotAt: '' })
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 1, page: 1, size: 20, pages: 1, candidates: [first!], complete: true, snapshotAt: '' })
|
||||
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original]); vi.mocked(api.lockDispatchDraftPallet).mockResolvedValue(original)
|
||||
vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey) })) }))
|
||||
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
|
||||
@@ -41,7 +41,7 @@ it('allows one atomic removal of a restored group that is no longer selectable',
|
||||
expect(api.unlockDispatchDraftPallet).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ source: first.selection })); wrapper.unmount()
|
||||
})
|
||||
const options = { props: { modelValue: true, orders: [] }, global: { plugins: [ElementPlus] } }
|
||||
beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(listAllPlatformWarehouses).mockResolvedValue([{ id: '321', warehouseName: '洛杉矶仓' }] as Awaited<ReturnType<typeof listAllPlatformWarehouses>>); vi.mocked(api.listDispatchDrafts).mockResolvedValue([draft()]); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [source()], complete: true, snapshotAt: '2026-10-19T00:00:00Z' }); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 })) })
|
||||
beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(listAllPlatformWarehouses).mockResolvedValue([{ id: '321', warehouseName: '洛杉矶仓' }] as Awaited<ReturnType<typeof listAllPlatformWarehouses>>); vi.mocked(api.listDispatchDrafts).mockResolvedValue([draft()]); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 1, page: 1, size: 20, pages: 1, candidates: [source()], complete: true, snapshotAt: '2026-10-19T00:00:00Z' }); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 })) })
|
||||
it.each([0, '0', '9007199254740993'])('transmits original authoritative batch version %s in complete draft conversion and exact retries', async recordVersion => {
|
||||
const original = draft()
|
||||
original.sources[0]!.appointmentBatches = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion, sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2 }] }]
|
||||
@@ -96,9 +96,9 @@ it('does not discard draft selections when recovery fails and never submits an i
|
||||
vi.mocked(api.listDispatchDrafts).mockRejectedValue(new Error('draft unavailable'))
|
||||
const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).toContain('排车候选或草稿恢复失败'); expect(api.convertDispatchDraft).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('refuses an incomplete candidate page without pretending its filtered rows are all available cargo', async () => {
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [], complete: false, snapshotAt: '' })
|
||||
const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft: (token: string) => void }; state.restoreDraft('server-token'); await flushPromises(); expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).toContain('尚未完整返回'); wrapper.unmount()
|
||||
it('accepts a partial candidate page without requiring the filtered whole set', async () => {
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 21, page: 1, size: 20, pages: 2, candidates: [source()], complete: false, snapshotAt: '' })
|
||||
const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft: (token: string) => void }; state.restoreDraft('server-token'); await flushPromises(); expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).not.toContain('尚未完整返回'); wrapper.unmount()
|
||||
})
|
||||
it('keeps vehicle assignments independent between containers and reuses a proven conversion after a detail read fails', async () => {
|
||||
const original = draft(); original.containers.push({ draftContainerId: 'container-B', sequenceNo: 2, capacity: 15, sourceKeys: ['source-B'] }); original.sources.push(candidate({ sourceKey: 'source-B', customerId: 'B' }))
|
||||
@@ -128,3 +128,22 @@ it('rejects a restored source whose batch customer differs from its real cargo o
|
||||
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise<void> }; state.restoreDraft('server-token'); await flushPromises(); await state.submit()
|
||||
expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).toContain('预约客户不一致'); wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps server draft cargo across pages, resets filters and sends regional tabs to the server', async () => {
|
||||
vi.mocked(api.listDispatchCandidates).mockImplementation(async query => ({ candidates: query.page === 2 ? [] : [source()], complete: false, snapshotAt: '', total: 21, page: query.page!, size: query.size!, pages: Math.ceil(21 / query.size!) }))
|
||||
vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey) })) }))
|
||||
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
|
||||
try {
|
||||
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; changePage(page: number): void; changeTab(tab: string): void; changeSize(size: number): void; query: { orderNo: string }; search(): Promise<void>; submit(): Promise<void> }
|
||||
state.restoreDraft('server-token'); await flushPromises(); state.changePage(2); await flushPromises()
|
||||
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, size: 20 }))
|
||||
state.query.orderNo = 'query'; await state.search()
|
||||
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, orderNo: 'query' }))
|
||||
state.changeTab('NYC'); await flushPromises()
|
||||
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, warehouseTab: 'NYC' }))
|
||||
state.changeSize(10); await flushPromises()
|
||||
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 10 }))
|
||||
state.changePage(2); await flushPromises(); await state.submit()
|
||||
expect(api.convertDispatchDraft).toHaveBeenCalledWith(expect.objectContaining({ groups: [expect.objectContaining({ dispatch: expect.objectContaining({ cargo: [source().selection] }) })] }))
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
|
||||
@@ -15,13 +15,14 @@ import LineClampText from '@/shared/components/LineClampText.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import * as api from '../api/dispatches'
|
||||
import { useDispatchCandidates } from '../useDispatchCandidates'
|
||||
import type { Candidate, ConvertedDraft, ConvertDraft, CreateDispatch, DispatchDraft, Driver, DraftSelection, Vehicle, VersionCommand } from '../dispatchContracts'
|
||||
import { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason } from '../dispatchContracts'
|
||||
import { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, selectionGroupBlockReason } from '../dispatchContracts'
|
||||
import { dispatchActionPermissions } from '../dispatchPresentation'
|
||||
import { useDispatchCommandRecovery, validateCurrentDispatch, isDefiniteDispatchRejection } from '../dispatchCommandRecovery'
|
||||
import type { InboundOrderListItem } from '../types'
|
||||
import DispatchResourceFields from './DispatchResourceFields.vue'
|
||||
import { matchesSmartDispatchWarehouseTab, type SmartDispatchWarehouseTab } from '../smartDispatchWarehouseTab'
|
||||
import type { SmartDispatchWarehouseTab } from '../smartDispatchWarehouseTab'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; orders: InboundOrderListItem[] }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; created: [] }>()
|
||||
@@ -33,10 +34,12 @@ const pending = ref<DraftCommand | null>(null)
|
||||
if (owner && sessionStorage.getItem(storageKey)) pending.value = JSON.parse(sessionStorage.getItem(storageKey)!) as DraftCommand
|
||||
const mode = ref<'cargo' | 'empty'>('cargo'), warehouseId = ref(''), warehouses = ref<PlatformWarehouseResource[]>([])
|
||||
const draft = ref<DispatchDraft | null>(null), drafts = ref<DispatchDraft[]>([]), activeContainerId = ref(''), capacity = ref(30)
|
||||
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), candidates = ref<Candidate[]>([])
|
||||
const working = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const candidatePage = useDispatchCandidates(error)
|
||||
const { candidates, page, size, total } = candidatePage
|
||||
const loading = computed(() => working.value || candidatePage.loading.value)
|
||||
const query = reactive({ orderNo: '', destinationWarehouseCode: '', sourceStage: '' })
|
||||
const warehouseTab = ref<SmartDispatchWarehouseTab>('ALL')
|
||||
const visibleCandidates = computed(() => candidates.value.filter(row => matchesSmartDispatchWarehouseTab(row, warehouseTab.value)))
|
||||
const settings = reactive<Record<string, { form: { plannedDepartureAt: string; estimatedArrivalAt: string; remark: string }; vehicle: Vehicle; driver: Driver }>>({})
|
||||
const currentSettings = computed(() => {
|
||||
const key = mode.value === 'empty' ? 'empty' : activeContainerId.value || 'unselected'
|
||||
@@ -60,16 +63,20 @@ async function startCargo() {
|
||||
if (!draft.value) await draftAction('CREATE')
|
||||
if (draft.value && !error.value && !pending.value && !draft.value.containers.length) await draftAction('CONTAINER')
|
||||
}
|
||||
function changeWarehouse() { candidates.value = []; warehouseTab.value = 'ALL'; void search() }
|
||||
function changeWarehouse() { candidatePage.reset(); warehouseTab.value = 'ALL'; void search() }
|
||||
function changeTab(tab: SmartDispatchWarehouseTab) { warehouseTab.value = tab; void search() }
|
||||
function changePage(value: number) { page.value = value; void loadCandidates() }
|
||||
function changeSize(value: number) { size.value = value; void search() }
|
||||
|
||||
let generation = 0
|
||||
let candidateFilterKey = ''
|
||||
function persist() {
|
||||
if (!owner || owner !== auth.user?.id) throw new DispatchInputError('登录身份已变化,请重新进入排车列表')
|
||||
if (pending.value) sessionStorage.setItem(storageKey, JSON.stringify(pending.value)); else sessionStorage.removeItem(storageKey)
|
||||
}
|
||||
async function init() {
|
||||
const sequence = ++generation
|
||||
loading.value = true; error.value = null
|
||||
working.value = true; error.value = null
|
||||
try {
|
||||
if (recovery.pending.value?.action === 'CREATE') { mode.value = 'empty'; warehouseId.value = String(recovery.pending.value.body.warehouseId || '') }
|
||||
const [directory, saved] = await Promise.all([listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' }), api.listDispatchDrafts()])
|
||||
@@ -83,20 +90,18 @@ async function init() {
|
||||
}
|
||||
if (warehouseId.value) await loadCandidates(sequence)
|
||||
} catch (cause) { if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车候选或草稿恢复失败,请保留原选择重试') }
|
||||
finally { if (sequence === generation) loading.value = false }
|
||||
finally { if (sequence === generation) working.value = false }
|
||||
}
|
||||
async function loadCandidates(sequence = generation) {
|
||||
if (!warehouseId.value) return
|
||||
const result = await api.listDispatchCandidates({ warehouseId: warehouseId.value, orderNo: query.orderNo || undefined, destinationWarehouseCode: query.destinationWarehouseCode || undefined, sourceStage: query.sourceStage || undefined, ...(query.sourceStage !== 'RELABEL' && planIds.value.length ? { planIds: planIds.value } : {}), draftToken: draft.value?.draftToken })
|
||||
if (sequence !== generation) return
|
||||
if (result.complete !== true) throw new DispatchInputError('候选来源尚未完整返回,请缩小筛选范围重试')
|
||||
if (result.candidates.some(row => row.warehouseId !== warehouseId.value || !row.sourceVersion || row.selection.sourceVersion !== row.sourceVersion)) throw new DispatchInputError('候选起运仓或来源版本不完整,请重新读取')
|
||||
candidates.value = result.candidates
|
||||
const filterKey = JSON.stringify([warehouseId.value, query, warehouseTab.value, planIds.value, draft.value?.draftToken])
|
||||
if (filterKey !== candidateFilterKey) { page.value = 1; candidateFilterKey = filterKey }
|
||||
await candidatePage.load({ warehouseId: warehouseId.value, page: page.value, size: size.value, warehouseTab: warehouseTab.value, orderNo: query.orderNo || undefined, destinationWarehouseCode: query.destinationWarehouseCode || undefined, sourceStage: query.sourceStage || undefined, ...(query.sourceStage !== 'RELABEL' && planIds.value.length ? { planIds: planIds.value } : {}), draftToken: draft.value?.draftToken })
|
||||
}
|
||||
async function search() { if (frozen.value) return; loading.value = true; error.value = null; try { await loadCandidates() } catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '货物候选读取失败') } finally { loading.value = false } }
|
||||
async function search() { if (frozen.value) return; page.value = 1; await loadCandidates() }
|
||||
function restoreDraft(token: string) { if (frozen.value) return; const value = drafts.value.find(row => row.draftToken === token); if (!value) return; draft.value = value; warehouseId.value = value.warehouseId; activeContainerId.value = value.containers[0]?.draftContainerId || ''; void search() }
|
||||
function creation(cargo: Candidate[], containerId?: string): CreateDispatch {
|
||||
assertSelectionGroupVersions(cargo, candidates.value)
|
||||
candidatePage.assertCurrent(cargo)
|
||||
requireBatchVersions(cargo.flatMap(row => row.appointmentBatches))
|
||||
if (cargo.some(row => row.warehouseId !== warehouseId.value || !row.sourceVersion || row.sourceVersion !== row.selection.sourceVersion || row.appointmentBatches.some(batch => batch.customerId !== row.customerId))) throw new DispatchInputError('货物仓库、来源版本或预约客户不一致,请重新读取')
|
||||
const config = containerId ? settings[containerId] : currentSettings.value
|
||||
@@ -115,7 +120,7 @@ async function draftAction(action: DraftCommand['action'], source?: Candidate, c
|
||||
if (!current) throw new DispatchInputError('请先新建或恢复草稿')
|
||||
const common = { recordVersion: requireVersion(current.recordVersion), idempotencyKey: key, draftToken: current.draftToken }
|
||||
if (action === 'CONTAINER') body = { ...common, capacity: capacity.value }
|
||||
else if (action === 'LOCK' || action === 'UNLOCK') { if (!source || !containerId) throw new DispatchInputError('请选择货物和装车容器'); if (action === 'LOCK') requireSelectionGroup(source); body = { ...common, draftContainerId: containerId, source: source.selection } }
|
||||
else if (action === 'LOCK' || action === 'UNLOCK') { if (!source || !containerId) throw new DispatchInputError('请选择货物和装车容器'); if (action === 'LOCK') { requireSelectionGroup(source); candidatePage.assertCurrent([source]) } body = { ...common, draftContainerId: containerId, source: source.selection } }
|
||||
else if (action === 'DELETE') body = { ...common, reason: '移除本草稿装车容器' }
|
||||
else if (action === 'RELEASE') body = common
|
||||
else {
|
||||
@@ -178,7 +183,7 @@ async function submit() {
|
||||
if (result) { emit('created'); emit('update:modelValue', false); ElMessage.success(`空排车单 ${result.dispatchNo} 已创建`) }
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '建单结果尚未核对,请使用原请求重试') }
|
||||
}
|
||||
watch(() => props.modelValue, value => { if (value) void init(); else generation++ }, { immediate: true })
|
||||
watch(() => props.modelValue, value => { if (value) void init(); else { generation++; candidatePage.reset() } }, { immediate: true })
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog class="admin-density-dialog admin-density admin-density-workspace admin-workbench is-dialog" append-to-body :lock-scroll="true" :model-value="modelValue" title="新建排车" width="min(1800px, calc(100vw - 40px))" fullscreenable :loading="loading" :confirm-loading="saving || recovery.busy.value" :confirm-disabled="confirmDisabled" :confirm-text="pending || recovery.pending.value ? '重试原请求' : mode === 'empty' ? '保存空排车单' : '生成整车排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
|
||||
@@ -209,8 +214,8 @@ watch(() => props.modelValue, value => { if (value) void init(); else generation
|
||||
<div class="cargo-workspace">
|
||||
<section class="density-dialog-section" aria-label="排车候选货物"><h3 class="density-dialog-section__title">候选货物 <small>重量/体积仅供预排参考</small></h3>
|
||||
<div class="dispatch-candidate-query"><el-input v-model="query.orderNo" :disabled="frozen || loading" placeholder="订单号/入仓码" /><el-input v-model="query.destinationWarehouseCode" :disabled="frozen || loading" placeholder="目的仓码" /><el-select v-model="query.sourceStage" :disabled="frozen || loading" clearable placeholder="全部来源"><el-option label="在途预排(已离港)" value="PLAN" /><el-option label="已收货实物" value="RECEIVED" /><el-option label="待出库换标" value="RELABEL" /></el-select><el-button :disabled="frozen || loading || !warehouseId" @click="search">查询</el-button></div>
|
||||
<div class="warehouse-tabs density-tabs" role="tablist" aria-label="候选仓库展示"><button type="button" v-for="tab in ([{ value: 'ALL', label: '全部' }, { value: 'LAX', label: '洛杉矶' }, { value: 'NYC', label: '纽约' }] as const)" :key="tab.value" role="tab" :aria-selected="warehouseTab === tab.value" :disabled="frozen || loading" @click="warehouseTab = tab.value">{{ tab.label }}</button></div>
|
||||
<el-table :data="visibleCandidates" border :empty-text="!warehouseId ? '请先选择起运仓' : '暂无符合条件的可排货物;在途货物请核对离港节点、入库计划和目的地'">
|
||||
<div class="warehouse-tabs density-tabs" role="tablist" aria-label="候选仓库展示"><button type="button" v-for="tab in ([{ value: 'ALL', label: '全部' }, { value: 'LAX', label: '洛杉矶' }, { value: 'NYC', label: '纽约' }] as const)" :key="tab.value" role="tab" :aria-selected="warehouseTab === tab.value" :disabled="frozen || loading" @click="changeTab(tab.value)">{{ tab.label }}</button></div>
|
||||
<el-table :data="candidates" border :empty-text="!warehouseId ? '请先选择起运仓' : '暂无符合条件的可排货物;在途货物请核对离港节点、入库计划和目的地'">
|
||||
<el-table-column label="操作" width="115" fixed="left"><template #default="{ row }"><el-button link :disabled="frozen || loading || !currentContainer || Boolean(selectionGroupBlockReason(row as Candidate)) || Boolean(assignedGroup(row as Candidate))" @click="draftAction('LOCK', row as Candidate, activeContainerId)">{{ assignedGroup(row as Candidate) ? '已加入第' + assignedGroup(row as Candidate)!.sequenceNo + '组' : '加入本组' }}</el-button></template></el-table-column>
|
||||
<el-table-column prop="customerName" label="客户" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="来源" min-width="130"><template #default="{ row }"><span class="density-dialog-note">{{ row.selection.sourceType === 'INBOUND_PLAN' ? (row.selection.warehousePalletId ? '已收货托盘' : '在途计划') : ({ RETURN_RECEIPT: '退回实收', RELABEL: '换标' } as Record<string, string>)[row.selection.sourceType] || '—' }}</span><LineClampText :content="row.selection.relabelNo || row.planNo || '—'" /></template></el-table-column>
|
||||
@@ -219,6 +224,7 @@ watch(() => props.modelValue, value => { if (value) void init(); else generation
|
||||
<el-table-column prop="availablePackages" label="可排箱数" width="100" /><el-table-column label="目的仓" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ [row.destination.warehouseCode || row.destination.address, row.destination.postalCode].filter(Boolean).join(' · ') || '—' }}</template></el-table-column>
|
||||
<el-table-column label="参考重量/体积" min-width="150"><template #default="{ row }">{{ row.suggestedWeightKg ?? '—' }}kg/{{ row.suggestedVolumeCbm ?? '—' }}m³</template></el-table-column><el-table-column prop="containerNo" label="集装箱号" min-width="170" show-overflow-tooltip /><el-table-column label="资格" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ selectionGroupBlockReason(row as Candidate) || '可排整组' }}</template></el-table-column>
|
||||
</el-table>
|
||||
<el-pagination aria-label="候选货物分页" :current-page="page" :page-size="size" :page-sizes="[10, 20, 50]" :total="total" :disabled="frozen || loading" layout="total, sizes, prev, pager, next" @update:current-page="changePage" @update:page-size="changeSize" />
|
||||
</section>
|
||||
<section class="density-dialog-section" aria-label="排车装车组"><h3 class="density-dialog-section__title">装车分组 <small>{{ populatedGroupCount }} 组有货</small><InlineHelp title="装车分组说明"><p>新建或恢复草稿后添加装车组。同仓可混装多个客户与目的仓。</p></InlineHelp></h3>
|
||||
<p v-if="!draft?.containers.length" class="density-dialog-note">暂无装车组</p>
|
||||
|
||||
@@ -46,7 +46,7 @@ export interface SelectionGroupSource { selection: CargoSelection; sourceKey: st
|
||||
export interface SelectionGroupPallet { warehousePalletId: string | null; palletNo: string | null; sourceKeys: string[]; packageCount: number }
|
||||
export interface SelectionGroup { groupKey: string; groupVersion: string; sources: SelectionGroupSource[]; pallets: SelectionGroupPallet[]; complete: boolean; blockReasons: string[] }
|
||||
export interface Candidate { selection: CargoSelection; sourceKey: string; sourceVersion: string; customerId: string; customerName: string; warehouseId: string; warehouseCode: string; warehouseName: string; orderNo: string | null; inboundCode: string | null; planNo: string | null; palletNo: string | null; containerNo: string | null; destination: Destination; availablePackages: number; suggestedWeightKg: number | null; suggestedVolumeCbm: number | null; measurementSource: string | null; appointmentBatches: DispatchBatchReference[]; blockReasons: string[]; selectionGroup: SelectionGroup }
|
||||
export interface CandidateQuery { warehouseId?: string; planIds?: string[]; orderNo?: string; destinationWarehouseCode?: string; sourceStage?: string; draftToken?: string }
|
||||
export interface CandidateQuery { warehouseId?: string; planIds?: string[]; orderNo?: string; destinationWarehouseCode?: string; sourceStage?: string; draftToken?: string; page?: number; size?: number; warehouseTab?: 'ALL' | 'LAX' | 'NYC' }
|
||||
export interface DispatchCargoItem { dispatchItemId: string; customerId: string; customerName: string; sourceKey: string; sourceVersion: string; sourceType: CargoSelection['sourceType']; planPalletId: string | null; returnReceiptId: string | null; relabelNo: string | null; warehousePalletId: string | null; palletNo: string | null; orderNo: string | null; inboundCode: string | null; destination: Destination; scheduledPackages: number; pickedPackages: number; remove: Gate }
|
||||
export interface DispatchItems { dispatchNo: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; items: DispatchCargoItem[] }
|
||||
export interface DispatchRouteStop { index: number; name: string; address: string | null; destinationKey: string | null }
|
||||
@@ -54,7 +54,7 @@ export interface DispatchRoute { dispatchNo: string; recordVersion: Version; sto
|
||||
export interface DispatchRouteCommand extends VersionCommand { destinationKeys: string[]; reason: string }
|
||||
export type DispatchEvidenceKind = 'VEHICLE' | 'DRIVER'
|
||||
export interface DispatchEvidenceFile { fileId: string; fileName: string; contentType: string; sizeBytes: number }
|
||||
export interface CandidateList { candidates: Candidate[]; snapshotAt: string; complete: boolean }
|
||||
export interface CandidateList { candidates: Candidate[]; snapshotAt: string; complete: boolean; total: number; page: number; size: number; pages: number }
|
||||
export interface DraftContainer { draftContainerId: string; sequenceNo: number; capacity: number; sourceKeys: string[] }
|
||||
export interface DispatchDraft { draftToken: string; recordVersion: Version; status: string; warehouseId: string; containers: DraftContainer[]; sources: Candidate[]; expiresAt: string }
|
||||
export interface DraftSelection extends VersionCommand { draftToken: string; draftContainerId: string; source: CargoSelection }
|
||||
|
||||
@@ -35,9 +35,9 @@ it('rejects changed group composition and conflicting source versions before cre
|
||||
it('normalizes nested group Long quantities and preserves source identities and versions without loss', () => {
|
||||
const row = sharedPalletCandidates()[0]!, id = '9007199254740993'
|
||||
const value: CandidateWire = { ...row, selectionGroup: { ...row.selectionGroup, sources: row.selectionGroup.sources.map(source => ({ ...source, customerId: id, selection: { ...source.selection, sourceVersion: id }, packages: String(source.packages) })), pallets: [{ warehousePalletId: id, palletNo: 'PALLET-1', sourceKeys: ['RELABEL:A', 'RELABEL:B'], packageCount: '8' }] } }
|
||||
const result = normalizeCandidateList({ candidates: [value], complete: true, snapshotAt: '' }).candidates[0]!
|
||||
const result = normalizeCandidateList({ total: 1, page: 1, size: 20, pages: 1, candidates: [value], complete: true, snapshotAt: '' }).candidates[0]!
|
||||
expect(result.selectionGroup.sources.map(source => source.packages)).toEqual([3, 5]); expect(result.selectionGroup.pallets[0]!.packageCount).toBe(8)
|
||||
expect(result.selectionGroup.sources[0]!.selection.sourceVersion).toBe(id); expect(result.selectionGroup.pallets[0]!.warehousePalletId).toBe(id)
|
||||
value.selectionGroup.sources[0]!.packages = '9007199254740993'
|
||||
expect(() => normalizeCandidateList({ candidates: [value], complete: true, snapshotAt: '' })).toThrow('安全整数')
|
||||
expect(() => normalizeCandidateList({ total: 1, page: 1, size: 20, pages: 1, candidates: [value], complete: true, snapshotAt: '' })).toThrow('安全整数')
|
||||
})
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('native dispatch Long quantity boundary', () => {
|
||||
it.each([0, '0', id])('keeps actual batch recordVersion %s lossless in candidates, drafts and existing dispatch references', version => {
|
||||
const reference = { batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: id, recordVersion: version, sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }
|
||||
const value = { ...source(), appointmentBatches: [reference] }
|
||||
expect(wire.normalizeCandidateList(parsed({ candidates: [value], complete: true, snapshotAt: '' })).candidates[0]!.appointmentBatches[0]!.recordVersion).toBe(version)
|
||||
expect(wire.normalizeCandidateList(parsed({ total: 1, page: 1, size: 20, pages: 1, candidates: [value], complete: true, snapshotAt: '' })).candidates[0]!.appointmentBatches[0]!.recordVersion).toBe(version)
|
||||
expect(wire.normalizeDispatchDraft(parsed({ ...draft(), sources: [value] })).sources[0]!.appointmentBatches[0]!.recordVersion).toBe(version)
|
||||
const existing = detail()
|
||||
expect(wire.normalizeDispatchDetail(parsed({ ...existing, dispatchInfo: { ...existing.dispatchInfo, appointmentBatches: [reference] } })).dispatchInfo.appointmentBatches[0]!.recordVersion).toBe(version)
|
||||
@@ -38,11 +38,11 @@ describe('native dispatch Long quantity boundary', () => {
|
||||
it.each([undefined, null, -1, '', 'bad'])('rejects missing or invalid authoritative candidate batch version %s', version => {
|
||||
const reference = { batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: id, recordVersion: version, sources: [] }
|
||||
const value = { ...source(), appointmentBatches: [reference] } as unknown as wire.CandidateWire
|
||||
expect(() => wire.normalizeCandidateList(parsed({ candidates: [value], complete: true, snapshotAt: '' }))).toThrow('预约批次版本缺失或无效,请重新读取候选')
|
||||
expect(() => wire.normalizeCandidateList(parsed({ total: 1, page: 1, size: 20, pages: 1, candidates: [value], complete: true, snapshotAt: '' }))).toThrow('预约批次版本缺失或无效,请重新读取候选')
|
||||
})
|
||||
it('preserves a raw large JSON integer that the shared parser safely converts into a version string', () => {
|
||||
const value = { ...source(), appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: id, recordVersion: 9007199254740992, sources: [] }] }
|
||||
expect(wire.normalizeCandidateList(parsed({ candidates: [value], complete: true, snapshotAt: '' })).candidates[0]!.appointmentBatches[0]!.recordVersion).toBe('9007199254740992')
|
||||
expect(wire.normalizeCandidateList(parsed({ total: 1, page: 1, size: 20, pages: 1, candidates: [value], complete: true, snapshotAt: '' })).candidates[0]!.appointmentBatches[0]!.recordVersion).toBe('9007199254740992')
|
||||
})
|
||||
it.each([undefined, null, true, false, '', ' ', ' 1', '1 ', '01', '+1', '-1', '1.0', '1e2', -1, 1.5, NaN, Infinity, '9007199254740992', 9007199254740992])('rejects invalid or unsafe quantity %s instead of silently changing it', value => {
|
||||
expect(() => wire.dispatchQuantity(value, '箱数')).toThrow('排车响应')
|
||||
@@ -63,7 +63,7 @@ describe('native dispatch Long quantity boundary', () => {
|
||||
expect(result.documents.outbounds[0]).toMatchObject({ customerId: id, packageCount: 12, palletCount: 2 }); expect(result.documents.deliveries[0]!.packageCount).toBe(12)
|
||||
})
|
||||
it('normalizes candidates and draft sources and keeps source versions and container IDs', () => {
|
||||
const candidates = wire.normalizeCandidateList(parsed({ candidates: [source()], snapshotAt: '', complete: true }))
|
||||
const candidates = wire.normalizeCandidateList(parsed({ total: 1, page: 1, size: 20, pages: 1, candidates: [source()], snapshotAt: '', complete: true }))
|
||||
expect(candidates.candidates[0]).toMatchObject({ availablePackages: 12, customerId: id, sourceVersion: 'source-hash' })
|
||||
const result = wire.normalizeDispatchDraft(parsed(draft()))
|
||||
expect(result.sources[0]!.availablePackages).toBe(12); expect(result.recordVersion).toBe(id); expect(result.containers[0]).toMatchObject({ draftContainerId: id, sequenceNo: 1, capacity: 30 })
|
||||
@@ -100,3 +100,12 @@ describe('native dispatch Long quantity boundary', () => {
|
||||
expect(() => wire.normalizeDispatchCommand({ ...command('EDIT'), counts: { ...counts, pickedPackageCount: undefined } } as unknown as wire.CommandResultWire)).toThrow('有效非负整数')
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes paginated long totals while keeping whole-group and page completeness independent', () => {
|
||||
const result = wire.normalizeCandidateList(parsed({ candidates: [source()], complete: false, snapshotAt: '', total: '41', pages: '3', page: 2, size: 20 }))
|
||||
expect(result).toMatchObject({ total: 41, pages: 3, page: 2, size: 20, complete: false })
|
||||
expect(result.candidates[0]!.selectionGroup.complete).toBe(true)
|
||||
})
|
||||
it.each([{ total: '9007199254740993' }, { pages: '9007199254740993' }, { total: undefined }, { size: 51 }, { page: 0 }, { pages: '2' }])('fails closed for invalid pagination metadata %j', overrides => {
|
||||
expect(() => wire.normalizeCandidateList(parsed({ candidates: [], complete: false, snapshotAt: '', total: '21', pages: '3', page: 2, size: 10, ...overrides } as unknown as wire.CandidateListWire))).toThrow(/候选分页信息无效|排车响应/)
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@ export type DispatchPageWire = Omit<DispatchPage, 'records' | 'total' | 'page' |
|
||||
export type DispatchDetailWire = Omit<DispatchDetail, 'counts' | 'documents'> & { counts: CountsWire; documents: Omit<DispatchDetail['documents'], keyof ReferencesWire> & ReferencesWire }
|
||||
type SelectionGroupWire = Omit<SelectionGroup, 'sources' | 'pallets'> & { sources: Array<LongFields<SelectionGroup['sources'][number], 'packages'>>; pallets: Array<LongFields<SelectionGroup['pallets'][number], 'packageCount'>> }
|
||||
export type CandidateWire = Omit<LongFields<Candidate, 'availablePackages'>, 'selectionGroup'> & { selectionGroup: SelectionGroupWire }
|
||||
export type CandidateListWire = Omit<CandidateList, 'candidates'> & { candidates: CandidateWire[] }
|
||||
export type CandidateListWire = Omit<LongFields<CandidateList, 'total' | 'pages'>, 'candidates'> & { candidates: CandidateWire[] }
|
||||
export type DispatchDraftWire = Omit<DispatchDraft, 'sources'> & { sources: CandidateWire[] }
|
||||
export type DispatchItemsWire = Omit<DispatchItems, 'items'> & { items: Array<LongFields<DispatchItems['items'][number], 'scheduledPackages' | 'pickedPackages'>> }
|
||||
export type LoadingPreviewWire = Omit<LoadingPreview, 'pallets'> & { pallets: Array<LongFields<LoadingPreview['pallets'][number], 'scheduledPackages' | 'pickedPackages'>> }
|
||||
@@ -60,7 +60,11 @@ function statistics(value: StatisticsWire): DispatchStatistics {
|
||||
}
|
||||
export function normalizeDispatchPage(value: DispatchPageWire): DispatchPage { return { ...value, total: dispatchQuantity(value.total, 'total'), page: dispatchQuantity(value.page, 'page'), size: dispatchQuantity(value.size, 'size'), pages: dispatchQuantity(value.pages, 'pages'), records: value.records.map(row => ({ ...row, counts: counts(row.counts) })), statistics: statistics(value.statistics) } }
|
||||
export function normalizeDispatchDetail(value: DispatchDetailWire): DispatchDetail { requireBatchVersions(value.dispatchInfo.appointmentBatches); return { ...value, counts: counts(value.counts), documents: { ...value.documents, ...references(value.documents) } } }
|
||||
export function normalizeCandidateList(value: CandidateListWire): CandidateList { return { ...value, candidates: value.candidates.map(candidate) } }
|
||||
export function normalizeCandidateList(value: CandidateListWire): CandidateList {
|
||||
const result = { ...value, candidates: value.candidates.map(candidate), total: dispatchQuantity(value.total, 'total'), page: dispatchQuantity(value.page, 'page'), size: dispatchQuantity(value.size, 'size'), pages: dispatchQuantity(value.pages, 'pages') }
|
||||
if (result.page < 1 || result.size < 1 || result.size > 50 || result.pages !== Math.ceil(result.total / result.size) || result.candidates.length > result.size) throw new DispatchInputError('候选分页信息无效,请重新读取')
|
||||
return result
|
||||
}
|
||||
export function normalizeDispatchDraft(value: DispatchDraftWire): DispatchDraft { return { ...value, sources: value.sources.map(candidate) } }
|
||||
export function normalizeDispatchItems(value: DispatchItemsWire): DispatchItems { return { ...value, items: value.items.map(row => ({ ...row, scheduledPackages: dispatchQuantity(row.scheduledPackages, 'scheduledPackages'), pickedPackages: dispatchQuantity(row.pickedPackages, 'pickedPackages') })) } }
|
||||
export function normalizeLoadingPreview(value: LoadingPreviewWire): LoadingPreview { return { ...value, pallets: value.pallets.map(row => ({ ...row, scheduledPackages: dispatchQuantity(row.scheduledPackages, 'scheduledPackages'), pickedPackages: dispatchQuantity(row.pickedPackages, 'pickedPackages') })) } }
|
||||
|
||||
@@ -13,5 +13,6 @@ export { DispatchInputError, requireVersion, requireBatchVersions, requireSelect
|
||||
export { useDispatchCommandRecovery, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection } from './dispatchCommandRecovery'
|
||||
export { default as DispatchWorkflowDialog } from './components/DispatchWorkflowDialog.vue'
|
||||
export { dispatchVehicleSpecs } from './dispatchVehicleSpecs'
|
||||
export { useDispatchCandidates } from './useDispatchCandidates'
|
||||
export { getContainerPlanProgress, getContainerInboundPlanPreview, convertLastMileContainerToInboundPlans } from './api/lastMileOrders'
|
||||
export type { ContainerPlanProgressDetailDto, ContainerInboundPlanPreviewDto, ConvertContainerInboundPlanPayload } from './api/lastMileOrders'
|
||||
|
||||
@@ -257,9 +257,9 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
|
||||
it('connects native same-warehouse multi-customer draft containers and independent resources', () => {
|
||||
const dialog = source('../components/SmartDispatchDialog.vue')
|
||||
for (const symbol of ['listDispatchCandidates', 'createDispatchDraft', 'lockDispatchDraftPallet', 'unlockDispatchDraftPallet', 'releaseDispatchDraft', 'convertDispatchDraft', 'requireVersion', 'draftContainerId', 'sourceVersion', 'result.complete !== true', 'validateCurrentDispatch', 'warehouse-tabs density-tabs', '洛杉矶', '纽约', '先建空单后加货', 'settings[containerId]']) expect(dialog).toContain(symbol)
|
||||
for (const symbol of ['useDispatchCandidates', 'createDispatchDraft', 'lockDispatchDraftPallet', 'unlockDispatchDraftPallet', 'releaseDispatchDraft', 'convertDispatchDraft', 'requireVersion', 'draftContainerId', 'sourceVersion', 'candidatePage.assertCurrent', 'validateCurrentDispatch', 'warehouse-tabs density-tabs', '洛杉矶', '纽约', '先建空单后加货', 'settings[containerId]']) expect(dialog).toContain(symbol)
|
||||
// A filtered candidate must still submit its complete server-owned selection group.
|
||||
expect(dialog).toContain('assertSelectionGroupVersions(cargo, candidates.value)')
|
||||
expect(dialog).toContain('candidatePage.assertCurrent(cargo)')
|
||||
expect(dialog).toContain('cargo: expandSelectionGroups(cargo)')
|
||||
expect(dialog).toContain('同仓可混装多个客户与目的仓')
|
||||
expect(dialog).not.toContain('一次排车只能装同一客户')
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ref } from 'vue'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { ApiErrorDetails } from '@/shared/api/http'
|
||||
import { listDispatchCandidates } from './api/dispatches'
|
||||
import { candidate, sharedPalletCandidates } from './dispatchTestFixtures'
|
||||
import type { CandidateList } from './dispatchContracts'
|
||||
import { useDispatchCandidates } from './useDispatchCandidates'
|
||||
|
||||
vi.mock('./api/dispatches', () => ({ listDispatchCandidates: vi.fn() }))
|
||||
beforeEach(() => vi.resetAllMocks())
|
||||
const response = (overrides: Partial<CandidateList> = {}): CandidateList => ({ candidates: [candidate()], snapshotAt: '', complete: false, page: 1, size: 20, total: 41, pages: 3, ...overrides })
|
||||
function deferred() {
|
||||
let resolve!: (value: CandidateList) => void, reject!: (error: Error) => void
|
||||
const promise = new Promise<CandidateList>((yes, no) => { resolve = yes; reject = no })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
function setup() {
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
return { ...useDispatchCandidates(error), error }
|
||||
}
|
||||
it('deduplicates concurrent identical requests and accepts complete groups on a partial page', async () => {
|
||||
const pending = deferred(), state = setup()
|
||||
vi.mocked(listDispatchCandidates).mockReturnValue(pending.promise)
|
||||
const first = state.load({ warehouseId: '321', page: 2, warehouseTab: 'LAX', orderNo: 'LM' })
|
||||
const second = state.load({ warehouseId: '321', page: 2, warehouseTab: 'LAX', orderNo: 'LM' })
|
||||
expect(first).toBe(second); expect(listDispatchCandidates).toHaveBeenCalledTimes(1)
|
||||
pending.resolve(response({ candidates: [sharedPalletCandidates()[0]!], page: 2 }))
|
||||
await first
|
||||
expect(state.total.value).toBe(41); expect(state.page.value).toBe(2)
|
||||
expect(state.candidates.value[0]!.selectionGroup.sources).toHaveLength(2)
|
||||
expect(() => state.assertCurrent(state.candidates.value)).not.toThrow()
|
||||
})
|
||||
it.each(['success', 'failure'])('ignores stale %s while the latest page is still loading', async outcome => {
|
||||
const old = deferred(), latest = deferred(), state = setup()
|
||||
vi.mocked(listDispatchCandidates).mockReturnValueOnce(old.promise).mockReturnValueOnce(latest.promise)
|
||||
const first = state.load({ warehouseId: '321', page: 1 })
|
||||
const second = state.load({ warehouseId: '321', page: 2 })
|
||||
if (outcome === 'success') old.resolve(response()); else old.reject(new Error('stale failure'))
|
||||
await first
|
||||
expect(state.loading.value).toBe(true); expect(state.error.value).toBeNull(); expect(state.candidates.value).toEqual([])
|
||||
latest.resolve(response({ page: 2 })); await second
|
||||
expect(state.page.value).toBe(2); expect(state.loading.value).toBe(false)
|
||||
})
|
||||
it('ignores an old response after a new warehouse succeeds and invalidates requests on close', async () => {
|
||||
const old = deferred(), state = setup(), other = candidate({ warehouseId: 'other' })
|
||||
vi.mocked(listDispatchCandidates).mockReturnValueOnce(old.promise).mockResolvedValueOnce(response({ candidates: [other] }))
|
||||
const first = state.load({ warehouseId: '321' })
|
||||
await state.load({ warehouseId: 'other' })
|
||||
old.resolve(response()); await first
|
||||
expect(state.candidates.value[0]!.warehouseId).toBe('other')
|
||||
expect(() => state.assertCurrent([candidate()])).toThrow('组成已变化')
|
||||
const closing = deferred()
|
||||
vi.mocked(listDispatchCandidates).mockReturnValueOnce(closing.promise)
|
||||
const request = state.load({ warehouseId: 'other', page: 2 })
|
||||
state.reset(); closing.resolve(response({ page: 2, candidates: [other] })); await request
|
||||
expect(state.candidates.value).toEqual([]); expect(state.total.value).toBe(0); expect(state.loading.value).toBe(false)
|
||||
})
|
||||
it('remembers a group or source version change even after navigating to an unrelated page', async () => {
|
||||
const state = setup(), selected = candidate()
|
||||
const changed = candidate({ selectionGroup: { ...selected.selectionGroup, groupVersion: 'changed' } })
|
||||
vi.mocked(listDispatchCandidates).mockResolvedValueOnce(response()).mockResolvedValueOnce(response({ page: 2, candidates: [changed] })).mockResolvedValueOnce(response({ page: 3, candidates: [] }))
|
||||
await state.load({ warehouseId: '321' }); await state.load({ warehouseId: '321', page: 2 }); await state.load({ warehouseId: '321', page: 3 })
|
||||
expect(() => state.assertCurrent([selected])).toThrow('组成已变化')
|
||||
expect(() => state.assertCurrent([changed])).not.toThrow()
|
||||
})
|
||||
it.each(['warehouse', 'page', 'version'])('rejects a mismatched %s response instead of exposing selectable stale rows', async mismatch => {
|
||||
const state = setup(), row = candidate()
|
||||
if (mismatch === 'warehouse') row.warehouseId = 'other'
|
||||
if (mismatch === 'version') row.sourceVersion = 'changed'
|
||||
vi.mocked(listDispatchCandidates).mockResolvedValue(response({ candidates: [row], page: mismatch === 'page' ? 2 : 1 }))
|
||||
await state.load({ warehouseId: '321' })
|
||||
expect(state.candidates.value).toEqual([]); expect(state.error.value).not.toBeNull()
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ref, type Ref } from 'vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { listDispatchCandidates } from './api/dispatches'
|
||||
import { DispatchInputError, assertSelectionGroupVersions, type Candidate, type CandidateQuery } from './dispatchContracts'
|
||||
|
||||
/** One visible server page; selected cargo remains owned by the caller or server draft. */
|
||||
export function useDispatchCandidates(error: Ref<ApiErrorDetails | null>, fetchCandidates = listDispatchCandidates) {
|
||||
const candidates = ref<Candidate[]>([]), page = ref(1), size = ref(20), total = ref(0), loading = ref(false)
|
||||
let generation = 0, warehouse = ''
|
||||
let pending: { key: string; promise: Promise<void> } | undefined
|
||||
const groupVersions = new Map<string, string>()
|
||||
const sourceGroups = new Map<string, { groupKey: string; sourceVersion: string }>()
|
||||
|
||||
function reset() {
|
||||
generation++; pending = undefined; warehouse = ''
|
||||
candidates.value = []; page.value = 1; total.value = 0; loading.value = false
|
||||
groupVersions.clear(); sourceGroups.clear()
|
||||
}
|
||||
function assertCurrent(rows: Candidate[]) {
|
||||
assertSelectionGroupVersions(rows, candidates.value)
|
||||
for (const row of rows) {
|
||||
const group = row.selectionGroup
|
||||
if (row.warehouseId !== warehouse || (groupVersions.has(group.groupKey) && groupVersions.get(group.groupKey) !== group.groupVersion)
|
||||
|| group.sources.some(source => {
|
||||
const latest = sourceGroups.get(source.sourceKey)
|
||||
return latest && (latest.groupKey !== group.groupKey || latest.sourceVersion !== source.selection.sourceVersion)
|
||||
})) throw new DispatchInputError('选货组组成已变化,请重新读取并选择货物')
|
||||
}
|
||||
}
|
||||
function load(query: CandidateQuery): Promise<void> {
|
||||
if (!query.warehouseId) { reset(); return Promise.resolve() }
|
||||
if (warehouse !== query.warehouseId) { reset(); warehouse = query.warehouseId }
|
||||
const request = { ...query, page: query.page ?? page.value, size: query.size ?? size.value, warehouseTab: query.warehouseTab ?? 'ALL' }
|
||||
const key = JSON.stringify(request)
|
||||
if (pending?.key === key) return pending.promise
|
||||
const sequence = ++generation
|
||||
// Preserve the last total while loading: zero would make the pager emit page 1.
|
||||
loading.value = true; error.value = null; candidates.value = []
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const result = await fetchCandidates(request)
|
||||
if (sequence !== generation) return
|
||||
if (result.page !== request.page || result.size !== request.size) throw new DispatchInputError('候选响应页码不一致,请重新读取')
|
||||
if (result.candidates.some(row => row.warehouseId !== request.warehouseId || !row.sourceVersion || row.selection.sourceVersion !== row.sourceVersion)) throw new DispatchInputError('候选起运仓或来源版本不完整,请重新读取')
|
||||
for (const row of result.candidates) {
|
||||
groupVersions.set(row.selectionGroup.groupKey, row.selectionGroup.groupVersion)
|
||||
for (const source of row.selectionGroup.sources) sourceGroups.set(source.sourceKey, { groupKey: row.selectionGroup.groupKey, sourceVersion: source.selection.sourceVersion })
|
||||
}
|
||||
candidates.value = result.candidates; page.value = result.page; size.value = result.size; total.value = result.total
|
||||
} catch (cause) {
|
||||
if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '货物候选读取失败')
|
||||
} finally {
|
||||
if (sequence === generation) { loading.value = false; pending = undefined }
|
||||
}
|
||||
})()
|
||||
pending = { key, promise }
|
||||
return promise
|
||||
}
|
||||
return { candidates, page, size, total, loading, load, reset, assertCurrent }
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import CarrierScheduleQueryPanel from './CarrierScheduleQueryPanel.vue'
|
||||
@@ -8,6 +8,10 @@ import { appendContainerShippingNode, getContainerShippingNodes } from '../api/p
|
||||
import { getPortSchedule } from '../api/platformPortSchedules'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { h } from 'vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
vi.mock('../api/platformCarrierScheduleQueries', () => ({
|
||||
listCarrierScheduleQueries: vi.fn(),
|
||||
@@ -91,6 +95,49 @@ describe('CarrierScheduleQueryPanel loading states', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each([false, true])('isolates nested manual entry from its parent and reopens in ordinary mode (dense=%s)', async (dense) => {
|
||||
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
|
||||
const host = mount(AppDialog, {
|
||||
props: { modelValue: true, title: '船期详情', showConfirm: false },
|
||||
attrs: { class: 'parent-schedule-dialog admin-density-dialog admin-workbench is-dialog' },
|
||||
slots: { default: () => h(CarrierScheduleQueryPanel, { oceanBookingId: '2', containerBookingId: '3', dense }) },
|
||||
global: { plugins: [ElementPlus, pinia] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
try {
|
||||
await flushPromises()
|
||||
const panel = host.findComponent(CarrierScheduleQueryPanel)
|
||||
const open = async () => {
|
||||
await panel.findAll('button').find(button => button.text().includes('人工补录节点'))!.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
await open()
|
||||
const child = panel.findComponent(AppDialog)
|
||||
const dialog = document.body.querySelector<HTMLElement>('.el-dialog:not(.parent-schedule-dialog)')!
|
||||
expect(dialog).not.toBeNull()
|
||||
expect(dialog.parentElement?.closest('.parent-schedule-dialog')).toBeNull()
|
||||
expect(document.body.contains(dialog)).toBe(true)
|
||||
expect(dialog.querySelector(':scope > .el-dialog__body .app-dialog__body')).not.toBeNull()
|
||||
expect(dialog.querySelector(':scope > .el-dialog__footer')).not.toBeNull()
|
||||
expect(child.props('width')).toContain(dense ? '780px' : '620px')
|
||||
if (dense) {
|
||||
dialog.querySelector<HTMLButtonElement>('[aria-label="展开全屏"]')!.click()
|
||||
await flushPromises()
|
||||
expect(dialog.classList.contains('is-fullscreen')).toBe(true)
|
||||
}
|
||||
Array.from(dialog.querySelectorAll<HTMLButtonElement>('.el-dialog__footer button')).find(button => button.textContent?.trim() === '取消')!.click()
|
||||
await flushPromises()
|
||||
expect(child.props('modelValue')).toBe(false)
|
||||
expect(host.props('modelValue')).toBe(true)
|
||||
await open()
|
||||
expect(child.props('modelValue')).toBe(true)
|
||||
expect(dialog.classList.contains('is-fullscreen')).toBe(false)
|
||||
expect(appendContainerShippingNode).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
host.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows manual entry without creating a tracking task', async () => {
|
||||
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
|
||||
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2', containerBookingId: '3' }, global: { plugins: [ElementPlus, pinia] } })
|
||||
@@ -291,14 +338,15 @@ describe('CarrierScheduleQueryPanel loading states', () => {
|
||||
inputs.find((input) => String(input.props('maxlength')) === '128')!.vm.$emit('update:modelValue', 'Pacific Ocean')
|
||||
inputs.find((input) => input.props('type') === 'textarea')!.vm.$emit('update:modelValue', '船司官网人工核验')
|
||||
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')
|
||||
expect(document.body.querySelector('.el-dialog')?.textContent).toContain('补录未成功,已保留当前填写内容')
|
||||
expect(document.body.querySelector('.el-dialog')?.textContent).toContain('req-409'); expect(document.body.querySelector('.el-dialog')?.textContent).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('船司官网人工核验')
|
||||
document.body.querySelector<HTMLButtonElement>('[aria-label="展开全屏"]')!.click()
|
||||
await flushPromises()
|
||||
expect(document.body.querySelector('.el-dialog.is-fullscreen')).not.toBeNull()
|
||||
expect(document.body.querySelector<HTMLTextAreaElement>('.el-dialog textarea')!.value).toBe('船司官网人工核验')
|
||||
}
|
||||
await wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
|
||||
@@ -323,7 +371,7 @@ describe('CarrierScheduleQueryPanel loading states', () => {
|
||||
wrapper.findAllComponents({ name: 'ElInput' }).find((input) => input.props('type') === 'textarea')!.vm.$emit('update:modelValue', '人工核验可提柜')
|
||||
await wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('预计时间和实际时间至少填写一项')
|
||||
expect(document.body.querySelector('.el-dialog')?.textContent).toContain('预计时间和实际时间至少填写一项')
|
||||
expect(appendContainerShippingNode).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -282,7 +282,7 @@ 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 || '—' }}</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-workbench is-dialog" :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">
|
||||
<AppDialog class="admin-workbench is-dialog" :class="{ 'admin-density-dialog': dense }" v-model="manualDialogOpen" :title="manualDialogTitle" :width="dense ? 'min(780px, calc(100vw - 28px))' : 'min(620px, calc(100vw - 28px))'" :fullscreenable="dense" append-to-body 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="观测时间按提交时刻记录;预计事实不推进实际进度,既有事实不可编辑或删除。" />
|
||||
|
||||
@@ -53,6 +53,42 @@ const attachment: TruckAttachmentReference = { id: 'attachment-1', fileId: 'file
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
afterEach(() => { vi.restoreAllMocks(); document.body.innerHTML = '' })
|
||||
describe('TruckAttachmentDialog workflows', () => {
|
||||
it('keeps a long file selection and attachment actions usable across fullscreen and reopening', async () => {
|
||||
const fileName = '司机证件与车辆资料'.repeat(20) + '.pdf'
|
||||
const longAttachment = { ...attachment, fileName }
|
||||
vi.mocked(uploadTruckAppointmentAttachment).mockResolvedValue(longAttachment)
|
||||
const wrapper = mount(TruckAttachmentDialog, { props: { modelValue: true, appointmentId: 'appointment-internal', attachments: [longAttachment], canManage: true }, global: { plugins: [ElementPlus] }, attachTo: document.body })
|
||||
try {
|
||||
await flushPromises()
|
||||
const input = wrapper.get<HTMLInputElement>('.attachment-picker input[type="file"]')
|
||||
const file = new File(['data'], fileName, { type: 'application/pdf' })
|
||||
Object.defineProperty(input.element, 'files', { configurable: true, value: [file] })
|
||||
await input.trigger('change')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.attachment-picker .el-upload-list__item-name').text()).toContain(fileName)
|
||||
expect(wrapper.get('.attachment-row strong').text()).toBe(fileName)
|
||||
expect(wrapper.get('.attachment-row button').text()).toBe('删除')
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="展开全屏"]')!.click()
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.attachment-picker .el-upload-list__item-name').text()).toContain(fileName)
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="退出全屏"]')!.click()
|
||||
await flushPromises()
|
||||
const upload = Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(button => button.textContent?.trim() === '上传并关联')!
|
||||
expect(upload.disabled).toBe(false)
|
||||
upload.click()
|
||||
await flushPromises()
|
||||
expect(uploadTruckAppointmentAttachment).toHaveBeenCalledExactlyOnceWith('appointment-internal', file, 'OTHER')
|
||||
await wrapper.setProps({ modelValue: false })
|
||||
await wrapper.setProps({ modelValue: true })
|
||||
await flushPromises()
|
||||
expect(document.querySelector('.el-dialog.is-fullscreen')).toBeNull()
|
||||
expect(wrapper.get('.attachment-row strong').text()).toBe(fileName)
|
||||
expect(deleteTruckAppointmentAttachment).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps attachment names readable through fullscreen and protects read-only access', async () => {
|
||||
const wrapper = mount(TruckAttachmentDialog, { props: { modelValue: true, appointmentId: 'appointment-internal', appointmentNo: 'TA-123', attachments: [attachment], canManage: false }, global: { plugins: [ElementPlus] }, attachTo: document.body })
|
||||
await flushPromises()
|
||||
|
||||
@@ -26,7 +26,7 @@ async function remove(item: TruckAttachmentReference) { if (busy.value || !props
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" :model-value="props.modelValue" :title="attachmentDialogTitle('预约附件')" width="min(640px, 94vw)" fullscreenable :show-confirm="false" :close-on-click-modal="true" @update:model-value="emit('update:modelValue', $event)">
|
||||
<el-alert v-if="error" type="error" :closable="true" show-icon @close="error = null"><template #title>{{ error.message }}</template><ApiErrorMeta :details="error" /></el-alert>
|
||||
<div class="density-dialog-sections">
|
||||
<section v-if="props.canManage" class="density-dialog-section" data-tone="work"><h3 class="density-dialog-section__title">上传附件</h3><div class="upload-panel"><el-select v-model="selectedType" aria-label="附件类型" class="attachment-type"><el-option v-for="(label, type) in attachmentTypeLabels" :key="type" :label="label" :value="type" /></el-select><el-upload :auto-upload="false" :limit="1" :show-file-list="true" :on-change="selectFile" :on-remove="() => { selectedFile = null }"><el-button><FileUp :size="15" />选择附件</el-button></el-upload><el-button type="primary" :loading="busy" :disabled="!selectedFile" @click="upload">上传并关联</el-button></div></section>
|
||||
<section v-if="props.canManage" class="density-dialog-section" data-tone="work"><h3 class="density-dialog-section__title">上传附件</h3><div class="upload-panel"><el-select v-model="selectedType" aria-label="附件类型" class="attachment-type"><el-option v-for="(label, type) in attachmentTypeLabels" :key="type" :label="label" :value="type" /></el-select><el-upload class="attachment-picker" :auto-upload="false" :limit="1" :show-file-list="true" :on-change="selectFile" :on-remove="() => { selectedFile = null }"><el-button><FileUp :size="15" />选择附件</el-button></el-upload><el-button type="primary" :loading="busy" :disabled="!selectedFile" @click="upload">上传并关联</el-button></div></section>
|
||||
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">现有附件 <small>共 {{ attachments.length }} 份</small></h3>
|
||||
<el-empty v-if="attachments.length === 0" description="暂无预约附件" :image-size="72" />
|
||||
<div v-for="item in attachments" :key="item.fileId" class="attachment-row">
|
||||
@@ -41,6 +41,8 @@ async function remove(item: TruckAttachmentReference) { if (busy.value || !props
|
||||
<style scoped>
|
||||
.upload-panel { display:flex; align-items:center; flex-wrap:wrap; gap:var(--admin-space-3); }
|
||||
.attachment-type { width:160px; }
|
||||
.attachment-picker { flex:1 1 220px; min-width:0; max-width:100%; }
|
||||
.upload-panel > .el-button, .attachment-row > .el-button { flex-shrink:0; }
|
||||
.attachment-row { display:flex; justify-content:space-between; gap:var(--admin-space-5); overflow-wrap:anywhere; margin:0; padding:var(--admin-space-3) 0; border:0; border-bottom:1px solid var(--workbench-border); border-radius:0; }.attachment-row small { display:block; margin-top:var(--admin-space-1); color:var(--el-text-color-secondary); }
|
||||
.attachment-row>div{min-width:0}.attachment-row strong{overflow-wrap:anywhere}
|
||||
@media (max-width: 560px) { .attachment-row { flex-direction:column; margin:0; padding:var(--admin-space-3) 0; border:0; border-bottom:1px solid var(--workbench-border); border-radius:0; } }
|
||||
|
||||
@@ -164,7 +164,7 @@ describe('LocationsView', () => {
|
||||
expect(locationsSource).not.toContain('AppDrawer')
|
||||
expect(locationsSource).not.toContain('FormSection')
|
||||
expect(locationsSource).toContain('width="min(1000px, calc(100vw - 28px))"')
|
||||
expect(densitySource).toContain('.admin-density-dialog.el-dialog .el-dialog__body { min-height: 0; overflow-y: auto;')
|
||||
expect(densitySource).toContain('.admin-density-dialog.el-dialog > .el-dialog__body { flex: 1 1 auto; min-height: 0; overflow-y: auto;')
|
||||
expect(locationsSource).toContain('.editor-field-grid { grid-template-columns: minmax(0, 1fr); }')
|
||||
})
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -132,7 +132,7 @@ describe('PalletsView', () => {
|
||||
expect(palletsSource).not.toContain('<AppDrawer')
|
||||
expect(palletsSource).toContain('width="min(560px, calc(100vw - 28px))" class="pallet-editor-dialog admin-density-dialog admin-workbench is-dialog"')
|
||||
expect(palletsSource).not.toContain('<el-drawer v-model="editorOpen"')
|
||||
expect(densitySource).toContain('.admin-density-dialog.el-dialog .el-dialog__body { min-height: 0; overflow-y: auto;')
|
||||
expect(densitySource).toContain('.admin-density-dialog.el-dialog > .el-dialog__body { flex: 1 1 auto; min-height: 0; overflow-y: auto;')
|
||||
expect(palletsSource).toContain('.editor-field-grid { grid-template-columns: minmax(0, 1fr); }')
|
||||
})
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { compileStyle, parse } from 'vue/compiler-sfc'
|
||||
import AppDialog from './AppDialog.vue'
|
||||
import AppDrawer from './AppDrawer.vue'
|
||||
import DataTable from './DataTable.vue'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
const styles: HTMLStyleElement[] = []
|
||||
|
||||
function installStyles() {
|
||||
const add = (css: string) => {
|
||||
const style = document.createElement('style')
|
||||
style.textContent = css.replace(/@import[^;]+;/g, '')
|
||||
document.head.append(style)
|
||||
styles.push(style)
|
||||
}
|
||||
for (const name of ['base', 'el-dialog', 'el-drawer', 'el-form', 'el-form-item', 'el-select', 'el-button']) {
|
||||
add(readFileSync(resolve(process.cwd(), `node_modules/element-plus/theme-chalk/${name}.css`), 'utf8'))
|
||||
}
|
||||
for (const [name, component] of [['AppDialog', AppDialog], ['AppDrawer', AppDrawer], ['DataTable', DataTable]] as const) {
|
||||
const source = readFileSync(resolve(process.cwd(), `src/shared/components/${name}.vue`), 'utf8')
|
||||
for (const style of parse(source).descriptor.styles) {
|
||||
const compiled = compileStyle({ source: style.content, filename: `${name}.vue`, scoped: style.scoped, id: component.__scopeId || 'data-v-layout' })
|
||||
expect(compiled.errors).toEqual([])
|
||||
add(compiled.code)
|
||||
}
|
||||
}
|
||||
for (const name of ['adminDensityTokens', 'adminDensity', 'adminWorkbenchTokens', 'adminWorkbench']) {
|
||||
add(readFileSync(resolve(process.cwd(), `src/styles/${name}.css`), 'utf8'))
|
||||
}
|
||||
}
|
||||
|
||||
async function render(template: string) {
|
||||
installStyles()
|
||||
wrapper = mount(defineComponent({
|
||||
components: { AppDialog, AppDrawer, DataTable },
|
||||
setup: () => ({ open: ref(true), childOpen: ref(true), name: ref(''), valid: ref(false) }),
|
||||
template,
|
||||
}), { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function element(selector: string) {
|
||||
const result = document.querySelector<HTMLElement>(selector)
|
||||
expect(result, selector).not.toBeNull()
|
||||
return result!
|
||||
}
|
||||
const computed = (selector: string) => getComputedStyle(element(selector))
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
styles.splice(0).forEach(style => style.remove())
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('real Element floating frame boundaries', () => {
|
||||
it.each(['workbench-table', 'workbench-toolbar'])('isolates %s toolbar actions from nested dialogs while preserving wrappers and dropdowns', async (surface) => {
|
||||
await render(`
|
||||
<main class="admin-workbench admin-density"><div class="${surface} is-page-surface">
|
||||
<DataTable :data="[]" :columns="[]" appearance="integrated" column-settings-mode="hide" :show-pagination="false">
|
||||
<template #toolbar-start><el-button class="start-action">返回</el-button></template>
|
||||
<template #toolbar-actions>
|
||||
<el-button type="primary" class="direct-action">新增</el-button>
|
||||
<div class="action-wrapper"><el-button type="primary" class="wrapped-action">批量操作</el-button>
|
||||
<el-dropdown><el-button type="primary" class="dropdown-action">更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item>导出</el-dropdown-item></el-dropdown-menu></template></el-dropdown>
|
||||
<el-dropdown split-button type="primary" class="split-actions">组合操作<template #dropdown><el-dropdown-menu><el-dropdown-item>另存</el-dropdown-item></el-dropdown-menu></template></el-dropdown>
|
||||
<el-button-group><el-button class="grouped-action">上一项</el-button><el-button>下一项</el-button></el-button-group>
|
||||
</div>
|
||||
<AppDialog v-model="open" title="新增模拟柜" class="toolbar-editor admin-density-dialog admin-density-dialog--comfortable admin-workbench is-dialog">
|
||||
<el-button type="primary" class="body-action">正文操作</el-button>
|
||||
</AppDialog>
|
||||
<DataTable :data="[]" :columns="[]" column-settings-mode="hide" :show-pagination="false">
|
||||
<template #toolbar-start><el-button type="primary" class="nested-table-action">子表操作</el-button></template>
|
||||
</DataTable>
|
||||
</template>
|
||||
<template #toolbar-end><div><el-button class="end-action">导出</el-button></div></template>
|
||||
</DataTable>
|
||||
</div></main>
|
||||
`)
|
||||
expect(element('.toolbar-editor').closest('.data-table__business-actions')).not.toBeNull()
|
||||
// Check matching against real DOM, not a source-order approximation of the cascade.
|
||||
const workbench = compileStyle({ source: readFileSync(resolve(process.cwd(), 'src/styles/adminWorkbench.css'), 'utf8'), filename: 'workbench.css', id: 'toolbar-regression' }).rawResult!.root
|
||||
const toolbarSelectors: string[] = []
|
||||
workbench.walkRules(rule => {
|
||||
if (/data-table__(toolbar|business-actions)/.test(rule.selector) && rule.selector.includes('el-button')) toolbarSelectors.push(...rule.selectors)
|
||||
})
|
||||
for (const selector of ['.direct-action', '.wrapped-action', '.dropdown-action', '.split-actions .el-button', '.grouped-action', '.start-action', '.end-action']) {
|
||||
expect(toolbarSelectors.some(rule => element(selector).matches(rule)), selector).toBe(true)
|
||||
}
|
||||
for (const button of document.querySelectorAll('.toolbar-editor .el-button, .nested-table-action')) {
|
||||
expect(toolbarSelectors.filter(rule => button.matches(rule))).toEqual([])
|
||||
}
|
||||
const table = parse(readFileSync(resolve(process.cwd(), 'src/shared/components/DataTable.vue'), 'utf8')).descriptor
|
||||
for (const style of table.styles) {
|
||||
const rules = compileStyle({ source: style.content, filename: 'DataTable.vue', scoped: style.scoped, id: DataTable.__scopeId || 'data-v-layout' }).rawResult!.root
|
||||
rules.walkRules(rule => {
|
||||
if (!/toolbar|business-actions|utility-actions/.test(rule.selector) || !rule.selector.includes('el-button')) return
|
||||
for (const button of document.querySelectorAll('.toolbar-editor .el-button, .nested-table-action')) {
|
||||
expect(rule.selectors.some(selector => button.matches(selector)), rule.selector).toBe(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
expect(computed('.toolbar-editor .app-dialog__footer > .el-button--primary').height).toBe('38px')
|
||||
expect(computed('.toolbar-editor .app-dialog__footer > .el-button--primary').boxShadow).not.toContain('4px 12px')
|
||||
})
|
||||
|
||||
it('keeps a comfortable parent from changing nested dense and native dialog chrome', async () => {
|
||||
await render(`
|
||||
<AppDialog v-model="open" title="父窗口" class="outer admin-density-dialog admin-density-dialog--comfortable admin-workbench is-dialog" fullscreenable>
|
||||
<el-form class="parent-form" label-position="top">
|
||||
<el-form-item label="父窗口字段"><el-select><el-option label="选项" value="one" /></el-select></el-form-item>
|
||||
<div class="parent-grid"><el-form-item label="网格字段"><el-select><el-option label="选项" value="one" /></el-select></el-form-item></div>
|
||||
</el-form>
|
||||
<AppDialog v-model="childOpen" title="子窗口" class="inner admin-density-dialog admin-workbench is-dialog">
|
||||
<el-form class="density-form"><el-form-item label="子窗口字段"><el-select><el-option label="选项" value="one" /></el-select></el-form-item></el-form>
|
||||
<el-form label-position="top"><el-form-item label="顶部标签"><el-select size="small"><el-option label="选项" value="one" /></el-select></el-form-item></el-form>
|
||||
</AppDialog>
|
||||
<el-dialog :model-value="true" title="原生预览" class="nested-native"><p>预览内容</p></el-dialog>
|
||||
</AppDialog>
|
||||
<AppDialog :model-value="true" title="独立窗口" class="reference admin-density-dialog admin-workbench is-dialog">
|
||||
<el-form class="density-form"><el-form-item label="子窗口字段"><el-select><el-option label="选项" value="one" /></el-select></el-form-item></el-form>
|
||||
<el-form label-position="top"><el-form-item label="顶部标签"><el-select size="small"><el-option label="选项" value="one" /></el-select></el-form-item></el-form>
|
||||
</AppDialog>
|
||||
<el-dialog :model-value="true" title="独立预览" class="reference-native"><p>预览内容</p></el-dialog>
|
||||
`)
|
||||
expect(element('.inner').closest('.outer')).not.toBeNull()
|
||||
expect(element('.outer').classList.contains('el-dialog')).toBe(true)
|
||||
for (const field of ['.parent-form > .el-form-item', '.parent-grid > .el-form-item']) {
|
||||
expect(computed(`.outer ${field}`).marginBottom).toBe('20px')
|
||||
expect(computed(`.outer ${field} > .el-form-item__label`).marginBottom).toBe('8px')
|
||||
expect(computed(`.outer ${field} > .el-form-item__label`).fontWeight).toBe('500')
|
||||
expect(computed(`.outer ${field} .el-select__wrapper`).minHeight).toBe('40px')
|
||||
}
|
||||
for (const part of ['header', 'body', 'footer']) {
|
||||
expect(computed(`.inner > .el-dialog__${part}`).padding).toBe(computed(`.reference > .el-dialog__${part}`).padding)
|
||||
}
|
||||
expect(computed('.nested-native > .el-dialog__header').padding).toBe(computed('.reference-native > .el-dialog__header').padding)
|
||||
expect(computed('.nested-native > .el-dialog__body').overflowY).toBe(computed('.reference-native > .el-dialog__body').overflowY)
|
||||
expect(computed('.inner > .el-dialog__header > .el-dialog__title').paddingRight).toBe(computed('.reference > .el-dialog__header > .el-dialog__title').paddingRight)
|
||||
for (const form of ['.density-form', '.el-form--label-top']) {
|
||||
expect(computed(`.inner ${form} .el-form-item`).marginBottom).toBe(computed(`.reference ${form} .el-form-item`).marginBottom)
|
||||
expect(computed(`.inner ${form} .el-form-item__label`).marginBottom).toBe(computed(`.reference ${form} .el-form-item__label`).marginBottom)
|
||||
expect(computed(`.inner ${form} .el-form-item__label`).fontWeight).toBe(computed(`.reference ${form} .el-form-item__label`).fontWeight)
|
||||
expect(computed(`.inner ${form} .el-select__wrapper`).minHeight).toBe(computed(`.reference ${form} .el-select__wrapper`).minHeight)
|
||||
}
|
||||
})
|
||||
|
||||
it('isolates drawer chrome and honors an explicitly teleported nested dialog', async () => {
|
||||
await render(`
|
||||
<AppDrawer v-model="open" title="详情" class="outer-drawer admin-density-drawer admin-workbench is-drawer" size="900px">
|
||||
<el-drawer :model-value="true" title="原生抽屉" class="nested-drawer"><p>详情</p></el-drawer>
|
||||
<AppDialog v-model="childOpen" title="编辑" append-to-body class="portal admin-density-dialog admin-workbench is-dialog"><p>编辑内容</p></AppDialog>
|
||||
</AppDrawer>
|
||||
<el-drawer :model-value="true" title="独立抽屉" class="reference-drawer"><p>详情</p></el-drawer>
|
||||
`)
|
||||
expect(element('.outer-drawer').classList.contains('el-drawer')).toBe(true)
|
||||
expect(element('.nested-drawer').closest('.outer-drawer')).not.toBeNull()
|
||||
expect(element('.portal').closest('.outer-drawer')).toBeNull()
|
||||
expect(element('.portal').classList.contains('admin-workbench')).toBe(true)
|
||||
for (const part of ['header', 'body']) {
|
||||
expect(computed(`.nested-drawer > .el-drawer__${part}`).padding).toBe(computed(`.reference-drawer > .el-drawer__${part}`).padding)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the native header and footer around a flexible body through fullscreen and reopen', async () => {
|
||||
const view = await render(`
|
||||
<AppDialog v-model="open" title="船期表单" fullscreenable width="760px" class="editor admin-density-dialog admin-workbench is-dialog">
|
||||
<el-input v-model="name" aria-label="船名" />
|
||||
<template #footer><el-button>取消</el-button><el-button>保存模板</el-button><el-button>预览生成</el-button><el-button type="primary">生成船期</el-button></template>
|
||||
</AppDialog>
|
||||
`)
|
||||
expect(element('.editor > .el-dialog__header > .el-dialog__title').textContent).toBe('船期表单')
|
||||
expect(computed('.editor > .el-dialog__body').flexGrow).toBe('1')
|
||||
expect(computed('.editor > .el-dialog__body').minHeight).toBe('0px')
|
||||
expect(computed('.editor > .el-dialog__body').overflowY).toBe('auto')
|
||||
expect(computed('.editor > .el-dialog__footer').flexShrink).toBe('0')
|
||||
expect(computed('.editor > .el-dialog__footer').flexWrap).toBe('wrap')
|
||||
await view.get('input[aria-label="船名"]').setValue('已填写船名')
|
||||
element('.editor button[aria-label="展开全屏"]').click()
|
||||
await flushPromises()
|
||||
expect(element('.editor').classList.contains('is-fullscreen')).toBe(true)
|
||||
expect(computed('.editor > .el-dialog__body').flexGrow).toBe('1')
|
||||
expect((element('.editor input') as HTMLInputElement).value).toBe('已填写船名')
|
||||
const dialog = view.findComponent(AppDialog)
|
||||
dialog.vm.$emit('update:modelValue', false)
|
||||
await flushPromises()
|
||||
dialog.vm.$emit('update:modelValue', true)
|
||||
await flushPromises()
|
||||
expect(element('.editor').classList.contains('is-fullscreen')).toBe(false)
|
||||
expect((element('.editor input') as HTMLInputElement).value).toBe('已填写船名')
|
||||
expect(element('.editor > .el-dialog__footer').querySelectorAll('button')).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('preserves workspace dimensions and lets multiline cell errors occupy space inside long forms', async () => {
|
||||
await render(`
|
||||
<AppDialog v-model="open" title="长表单" width="1560px" fullscreenable class="workspace admin-density-dialog admin-density-workspace admin-workbench is-dialog">
|
||||
<el-form class="density-form">
|
||||
<table class="density-table density-table--editable"><tbody>
|
||||
<tr><td><el-form-item label="必填说明" required error="请补充完整的说明,长错误文字应在当前单元格内换行,并为下一行字段留出空间。"><el-input v-model="name" /></el-form-item></td></tr>
|
||||
<tr v-for="index in 30" :key="index"><td>明细 {{ index }}</td></tr>
|
||||
</tbody></table>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
`)
|
||||
expect(computed('.workspace').height).toBe('92vh')
|
||||
expect(computed('.workspace').maxHeight).toBe('92vh')
|
||||
expect(element('.workspace .el-form-item.is-required')).toBeDefined()
|
||||
await vi.waitFor(() => expect(document.querySelector('.workspace .el-form-item__error')).not.toBeNull())
|
||||
expect(element('.workspace .el-form-item__error').textContent).toContain('下一行字段')
|
||||
expect(computed('.workspace .el-form-item__error').position).toBe('static')
|
||||
expect(computed('.workspace .el-form-item__error').flexBasis).toBe('100%')
|
||||
expect(element('.workspace table').querySelectorAll('tr')).toHaveLength(31)
|
||||
expect(element('.workspace > .el-dialog__footer').closest('.el-dialog__body')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -104,7 +104,7 @@ describe('AppDialog', () => {
|
||||
|
||||
expect(source).toContain(':global(.app-dialog.el-dialog) { position: relative; display: flex;')
|
||||
expect(source).toContain('width: 100%; max-width: 100%; height: 100vh;')
|
||||
expect(source).toContain(':global(.app-dialog .el-dialog__body) { min-height: 0; overflow-y: auto;')
|
||||
expect(source).toContain(':global(.app-dialog > .el-dialog__body) { flex: 1 1 auto; min-height: 0; overflow-y: auto;')
|
||||
expect(source).toContain('max-height: calc(100svh - 20px)')
|
||||
})
|
||||
|
||||
|
||||
@@ -136,9 +136,11 @@ function closeDialog() {
|
||||
<style scoped>
|
||||
:global(.app-dialog.el-dialog) { position: relative; display: flex; max-height: min(90vh, 900px); flex-direction: column; overflow: hidden; }
|
||||
:global(.app-dialog.el-dialog.is-fullscreen) { width: 100%; max-width: 100%; height: 100vh; max-height: 100vh; margin: 0 !important; border-radius: 0; }
|
||||
:global(.app-dialog .el-dialog__header), :global(.app-dialog .el-dialog__footer) { flex: 0 0 auto; }
|
||||
:global(.app-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
|
||||
:global(.app-dialog.has-native-fullscreen-control .el-dialog__title) { display: block; padding-right: 44px; }
|
||||
:global(.app-dialog > .el-dialog__header), :global(.app-dialog > .el-dialog__footer) { flex: 0 0 auto; }
|
||||
:global(.app-dialog > .el-dialog__body) { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
|
||||
:global(.app-dialog > .el-dialog__footer:has(> .el-button)) { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 10px; }
|
||||
:global(.app-dialog > .el-dialog__footer > .el-button) { margin-left: 0; }
|
||||
:global(.app-dialog.has-native-fullscreen-control > .el-dialog__header > .el-dialog__title) { display: block; padding-right: 44px; }
|
||||
.app-dialog__header { display: flex; width: 100%; min-width: 0; align-items: center; justify-content: space-between; gap: 12px; color: var(--cc-text); font-size: 18px; font-weight: 600; line-height: 24px; }
|
||||
.app-dialog__header-content { min-width: 0; flex: 1; }
|
||||
.app-dialog__header > :deep(.el-button) { flex: 0 0 auto; margin-right: 24px; color: var(--cc-muted); }
|
||||
@@ -146,6 +148,6 @@ function closeDialog() {
|
||||
.app-dialog__native-fullscreen-control.is-without-close { right: 12px; }
|
||||
.app-dialog__body { min-height: 0; }
|
||||
.app-dialog__footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
|
||||
.app-dialog__footer :deep(.el-button) { margin-left: 0; }
|
||||
@media (max-width: 720px) { :global(.app-dialog.el-dialog) { max-height: calc(100svh - 20px); margin-block: 10px !important; } .app-dialog__footer :deep(.el-button) { min-height: 44px; } }
|
||||
.app-dialog__footer > :deep(.el-button) { margin-left: 0; }
|
||||
@media (max-width: 720px) { :global(.app-dialog.el-dialog) { max-height: calc(100svh - 20px); margin-block: 10px !important; } .app-dialog__footer > :deep(.el-button) { min-height: 44px; } }
|
||||
</style>
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('AppDrawer', () => {
|
||||
it('keeps the drawer chrome fixed while long content scrolls in the body', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/shared/components/AppDrawer.vue'), 'utf8')
|
||||
|
||||
expect(source).toContain(':global(.app-drawer .el-drawer__body) { min-height: 0; overflow-y: auto;')
|
||||
expect(source).toContain(':global(.app-drawer > .el-drawer__body) { min-height: 0; overflow-y: auto;')
|
||||
expect(source).toContain('max-width: 94vw')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -88,10 +88,12 @@ function closeDrawer() {
|
||||
|
||||
<style scoped>
|
||||
:global(.app-drawer) { max-width: calc(100vw - 20px); }
|
||||
:global(.app-drawer .el-drawer__header), :global(.app-drawer .el-drawer__footer) { flex: 0 0 auto; }
|
||||
:global(.app-drawer .el-drawer__body) { min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
|
||||
:global(.app-drawer > .el-drawer__header), :global(.app-drawer > .el-drawer__footer) { flex: 0 0 auto; }
|
||||
:global(.app-drawer > .el-drawer__body) { min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
|
||||
:global(.app-drawer > .el-drawer__footer:has(> .el-button)) { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 10px; }
|
||||
:global(.app-drawer > .el-drawer__footer > .el-button) { margin-left: 0; }
|
||||
.app-drawer__body { min-height: 0; }
|
||||
.app-drawer__footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
|
||||
.app-drawer__footer :deep(.el-button) { margin-left: 0; }
|
||||
@media (max-width: 720px) { :global(.app-drawer) { max-width: 94vw; } .app-drawer__footer :deep(.el-button) { min-height: 44px; } }
|
||||
.app-drawer__footer > :deep(.el-button) { margin-left: 0; }
|
||||
@media (max-width: 720px) { :global(.app-drawer) { max-width: 94vw; } .app-drawer__footer > :deep(.el-button) { min-height: 44px; } }
|
||||
</style>
|
||||
|
||||
@@ -437,7 +437,10 @@ function setPage(value: number) {
|
||||
.data-table__toolbar-actions { display: flex; flex: 0 0 auto; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
.data-table__business-actions, .data-table__utility-actions, .data-table__toolbar-end { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
.data-table__utility-actions { flex: 0 0 auto; }
|
||||
.data-table__toolbar-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.data-table > .data-table__toolbar > .data-table__toolbar-actions > :is(.data-table__business-actions, .data-table__utility-actions) > :deep(.el-button + .el-button),
|
||||
.data-table > .data-table__toolbar > .data-table__toolbar-actions > :is(.data-table__business-actions, .data-table__utility-actions) > :deep(:where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button + .el-button),
|
||||
.data-table > .data-table__toolbar > .data-table__toolbar-actions > :is(.data-table__business-actions, .data-table__utility-actions) > :deep(:where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button + .el-button),
|
||||
.data-table > .data-table__toolbar > .data-table__toolbar-actions > :is(.data-table__business-actions, .data-table__utility-actions) > :deep(:where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button + .el-button) { margin-left: 0; }
|
||||
.data-table__pagination { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: var(--cc-list-pagination-font-size); }
|
||||
.data-table__pagination-left { display: flex; min-width: 0; flex: 1; align-items: center; gap: 8px; }
|
||||
.data-table__pagination-controls, .data-table__page-size, .data-table__page-buttons { display: flex; align-items: center; gap: 8px; }
|
||||
@@ -465,7 +468,10 @@ function setPage(value: number) {
|
||||
.data-table__page-ellipsis { width: 22px; height: 30px; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) { .data-table__toolbar, .data-table__toolbar.is-compact { min-height: 0; align-items: stretch; flex-direction: column; padding: 14px; } .data-table__toolbar-actions { width: 100%; align-items: flex-start; flex-wrap: wrap; justify-content: flex-start; } .data-table__toolbar-actions.has-business-actions { display: grid; grid-template-columns: minmax(0, 1fr) auto; } .data-table__business-actions { justify-content: flex-start; } .data-table__utility-actions { align-items: flex-start; justify-content: flex-end; } .data-table__toolbar-end { justify-content: flex-start; } .data-table__toolbar-actions :deep(.el-button) { max-width: 100%; min-height: 40px; } .data-table__pagination { align-items: flex-start; flex-direction: column; } .data-table__pagination-left, .data-table__pagination-controls, .data-table__page-buttons { width: 100%; } .data-table__pagination-controls { justify-content: flex-start; } .data-table__page-buttons { flex-wrap: wrap; } .data-table__page-button { width: 44px; min-width: 44px; height: 44px; } .data-table__page-ellipsis { height: 44px; } }
|
||||
@media (max-width: 720px) { .data-table__toolbar, .data-table__toolbar.is-compact { min-height: 0; align-items: stretch; flex-direction: column; padding: 14px; } .data-table__toolbar-actions { width: 100%; align-items: flex-start; flex-wrap: wrap; justify-content: flex-start; } .data-table__toolbar-actions.has-business-actions { display: grid; grid-template-columns: minmax(0, 1fr) auto; } .data-table__business-actions { justify-content: flex-start; } .data-table__utility-actions { align-items: flex-start; justify-content: flex-end; } .data-table__toolbar-end { justify-content: flex-start; } .data-table > .data-table__toolbar > .data-table__toolbar-actions > :is(.data-table__business-actions, .data-table__utility-actions) > :deep(.el-button),
|
||||
.data-table > .data-table__toolbar > .data-table__toolbar-actions > :is(.data-table__business-actions, .data-table__utility-actions) > :deep(:where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button),
|
||||
.data-table > .data-table__toolbar > .data-table__toolbar-actions > :is(.data-table__business-actions, .data-table__utility-actions) > :deep(:where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button),
|
||||
.data-table > .data-table__toolbar > .data-table__toolbar-actions > :is(.data-table__business-actions, .data-table__utility-actions) > :deep(:where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button) { max-width: 100%; min-height: 40px; } .data-table__pagination { align-items: flex-start; flex-direction: column; } .data-table__pagination-left, .data-table__pagination-controls, .data-table__page-buttons { width: 100%; } .data-table__pagination-controls { justify-content: flex-start; } .data-table__page-buttons { flex-wrap: wrap; } .data-table__page-button { width: 44px; min-width: 44px; height: 44px; } .data-table__page-ellipsis { height: 44px; } }
|
||||
/* Opt-in list surface: header, filters and results share one continuous card. */
|
||||
.data-table--integrated {
|
||||
--list-card-border: #e8edf5;
|
||||
@@ -494,19 +500,31 @@ function setPage(value: number) {
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.data-table--integrated .data-table__toolbar-actions :deep(.el-button) {
|
||||
.data-table--integrated > .data-table__toolbar > .data-table__toolbar-actions > :is(.data-table__business-actions, .data-table__utility-actions) > :deep(.el-button),
|
||||
.data-table--integrated > .data-table__toolbar > .data-table__toolbar-actions > :is(.data-table__business-actions, .data-table__utility-actions) > :deep(:where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button),
|
||||
.data-table--integrated > .data-table__toolbar > .data-table__toolbar-actions > :is(.data-table__business-actions, .data-table__utility-actions) > :deep(:where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button),
|
||||
.data-table--integrated > .data-table__toolbar > .data-table__toolbar-actions > :is(.data-table__business-actions, .data-table__utility-actions) > :deep(:where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button) {
|
||||
min-height: 36px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.data-table--integrated .data-table__utility-actions :deep(.el-button > span) { gap: 6px; }
|
||||
.data-table--integrated .data-table__business-actions :deep(.el-button--primary:not(:disabled)),
|
||||
.data-table--integrated > .data-table__toolbar > .data-table__toolbar-actions > .data-table__utility-actions > :deep(.el-button > span),
|
||||
.data-table--integrated > .data-table__toolbar > .data-table__toolbar-actions > .data-table__utility-actions > :deep(:where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button > span),
|
||||
.data-table--integrated > .data-table__toolbar > .data-table__toolbar-actions > .data-table__utility-actions > :deep(:where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button > span),
|
||||
.data-table--integrated > .data-table__toolbar > .data-table__toolbar-actions > .data-table__utility-actions > :deep(:where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button > span) { gap: 6px; }
|
||||
.data-table--integrated > .data-table__toolbar > .data-table__toolbar-actions > .data-table__business-actions > :deep(.el-button--primary:not(:disabled)),
|
||||
.data-table--integrated > .data-table__toolbar > .data-table__toolbar-actions > .data-table__business-actions > :deep(:where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button--primary:not(:disabled)),
|
||||
.data-table--integrated > .data-table__toolbar > .data-table__toolbar-actions > .data-table__business-actions > :deep(:where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button--primary:not(:disabled)),
|
||||
.data-table--integrated > .data-table__toolbar > .data-table__toolbar-actions > .data-table__business-actions > :deep(:where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button--primary:not(:disabled)),
|
||||
.data-table--integrated .data-table__page-button[aria-current="page"]:not(:disabled) {
|
||||
border-color: transparent;
|
||||
background: var(--list-card-accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 12px rgb(102 126 234 / 20%);
|
||||
}
|
||||
.data-table--integrated .data-table__business-actions :deep(.el-button--primary:hover:not(:disabled)),
|
||||
.data-table--integrated > .data-table__toolbar > .data-table__toolbar-actions > .data-table__business-actions > :deep(.el-button--primary:hover:not(:disabled)),
|
||||
.data-table--integrated > .data-table__toolbar > .data-table__toolbar-actions > .data-table__business-actions > :deep(:where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button--primary:hover:not(:disabled)),
|
||||
.data-table--integrated > .data-table__toolbar > .data-table__toolbar-actions > .data-table__business-actions > :deep(:where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button--primary:hover:not(:disabled)),
|
||||
.data-table--integrated > .data-table__toolbar > .data-table__toolbar-actions > .data-table__business-actions > :deep(:where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button--primary:hover:not(:disabled)),
|
||||
.data-table--integrated .data-table__page-button[aria-current="page"]:hover:not(:disabled) { filter: brightness(1.06); }
|
||||
.data-table--integrated > .data-table__filters {
|
||||
min-width: 0;
|
||||
|
||||
+27
-20
@@ -35,8 +35,8 @@
|
||||
.admin-density-dialog.el-dialog.is-fullscreen { width: 100%; max-width: 100%; height: 100vh; max-height: 100vh; margin: 0; border-radius: 0; }
|
||||
.admin-density-dialog.el-dialog.admin-density-workspace:not(.is-fullscreen) { height: 92vh; max-height: 92vh; margin: 4vh auto; }
|
||||
.admin-density-dialog.el-dialog.admin-density-workspace > .el-dialog__body { flex: 1 1 auto; }
|
||||
.admin-density-dialog .el-dialog__headerbtn { top: 0; }
|
||||
.admin-density-dialog .app-dialog__native-fullscreen-control { top: 6px; }
|
||||
.admin-density-dialog > .el-dialog__header > .el-dialog__headerbtn { top: 0; }
|
||||
.admin-density-dialog > .el-dialog__body > .app-dialog__native-fullscreen-control { top: 6px; }
|
||||
.admin-density-confirm.el-message-box { --el-font-size-base: var(--admin-font-body); border-radius: var(--admin-radius, 4px); padding: var(--admin-space-5, 12px) var(--admin-space-6, 16px); }
|
||||
.admin-density-confirm .el-message-box__title { font-size: var(--admin-font-title, 16px); }
|
||||
|
||||
@@ -51,14 +51,14 @@
|
||||
border-radius: var(--admin-radius, 4px);
|
||||
}
|
||||
.admin-density-dialog.el-dialog:not(.is-fullscreen) { margin-top: 6vh; max-height: 88vh; }
|
||||
.admin-density-dialog.el-dialog .el-dialog__header { flex: 0 0 auto; margin: 0; padding: var(--admin-space-4, 10px) 48px var(--admin-space-4, 10px) var(--admin-space-6, 16px); border-bottom: 1px solid var(--cc-border); }
|
||||
.admin-density-dialog.el-dialog .el-dialog__title { font-size: var(--admin-font-title, 16px); line-height: 24px; font-weight: 600; }
|
||||
.admin-density-dialog.el-dialog .el-dialog__body { min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: var(--admin-space-5, 12px) var(--admin-space-6, 16px); }
|
||||
.admin-density-dialog.el-dialog .el-dialog__footer { flex: 0 0 auto; padding: var(--admin-space-3, 8px) var(--admin-space-6, 16px); border-top: 1px solid var(--cc-border); }
|
||||
.admin-density-dialog.el-dialog > .el-dialog__header { flex: 0 0 auto; margin: 0; padding: var(--admin-space-4, 10px) 48px var(--admin-space-4, 10px) var(--admin-space-6, 16px); border-bottom: 1px solid var(--cc-border); }
|
||||
.admin-density-dialog.el-dialog > .el-dialog__header > .el-dialog__title { font-size: var(--admin-font-title, 16px); line-height: 24px; font-weight: 600; }
|
||||
.admin-density-dialog.el-dialog > .el-dialog__body { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: var(--admin-space-5, 12px) var(--admin-space-6, 16px); }
|
||||
.admin-density-dialog.el-dialog > .el-dialog__footer { flex: 0 0 auto; padding: var(--admin-space-3, 8px) var(--admin-space-6, 16px); border-top: 1px solid var(--cc-border); }
|
||||
/* Compact confirmations keep one message and right-aligned actions without section dividers. */
|
||||
.admin-density-dialog.density-confirm-dialog.el-dialog:not(.is-fullscreen) { margin: auto; }
|
||||
.admin-density-dialog.density-confirm-dialog.el-dialog .el-dialog__header { border-bottom: 0; }
|
||||
.admin-density-dialog.density-confirm-dialog.el-dialog .el-dialog__footer { border-top: 0; padding-bottom: var(--admin-space-5, 12px); }
|
||||
.admin-density-dialog.density-confirm-dialog.el-dialog > .el-dialog__header { border-bottom: 0; }
|
||||
.admin-density-dialog.density-confirm-dialog.el-dialog > .el-dialog__footer { border-top: 0; padding-bottom: var(--admin-space-5, 12px); }
|
||||
.admin-density-dialog.el-dialog .el-table { font-size: var(--admin-font-body, 13px); }
|
||||
.admin-density-dialog.el-dialog .el-table__cell { height: var(--admin-row-height, 36px); padding: var(--admin-space-1, 4px) 0; }
|
||||
.admin-density-dialog.el-dialog .el-table .cell { line-height: 22px; }
|
||||
@@ -72,14 +72,19 @@
|
||||
/* Opt-in comfortable editor frame for short forms that need room to breathe. */
|
||||
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog { --el-component-size: 40px; border-radius: 12px; }
|
||||
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog:not(.is-fullscreen) { margin-top: 10vh; }
|
||||
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog .el-dialog__header { padding: 18px 48px 18px 24px; }
|
||||
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog .el-dialog__headerbtn { top: 6px; }
|
||||
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog .el-dialog__body { padding: 24px; }
|
||||
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog .el-dialog__footer { padding: 16px 24px; background: var(--admin-label-bg); }
|
||||
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog .el-form-item { margin-bottom: 20px; }
|
||||
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog .el-form-item__label { margin-bottom: 8px; font-weight: 500; }
|
||||
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog .el-select__wrapper { min-height: 40px; }
|
||||
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog .app-dialog__footer .el-button { min-width: 88px; height: 38px; }
|
||||
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog > .el-dialog__header { padding: 18px 48px 18px 24px; }
|
||||
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog > .el-dialog__header > .el-dialog__headerbtn { top: 6px; }
|
||||
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog > .el-dialog__body { padding: 24px; }
|
||||
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog > .el-dialog__footer { padding: 16px 24px; background: var(--admin-label-bg); }
|
||||
/* Short forms opt in through the body's own form, with direct fields or one grid wrapper.
|
||||
Never cross a nested overlay/dialog/drawer while styling its fields. */
|
||||
.admin-density-dialog--comfortable.el-dialog > .el-dialog__body > .app-dialog__body > .el-form > .el-form-item,
|
||||
.admin-density-dialog--comfortable.el-dialog > .el-dialog__body > .app-dialog__body > .el-form > :where(div):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper)) > .el-form-item { margin-bottom: 20px; }
|
||||
.admin-density-dialog--comfortable.el-dialog > .el-dialog__body > .app-dialog__body > .el-form > .el-form-item > .el-form-item__label,
|
||||
.admin-density-dialog--comfortable.el-dialog > .el-dialog__body > .app-dialog__body > .el-form > :where(div):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper)) > .el-form-item > .el-form-item__label { margin-bottom: 8px; font-weight: 500; }
|
||||
.admin-density-dialog--comfortable.el-dialog > .el-dialog__body > .app-dialog__body > .el-form > .el-form-item > .el-form-item__content > .el-select > .el-select__wrapper,
|
||||
.admin-density-dialog--comfortable.el-dialog > .el-dialog__body > .app-dialog__body > .el-form > :where(div):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper)) > .el-form-item > .el-form-item__content > .el-select > .el-select__wrapper { min-height: 40px; }
|
||||
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog > .el-dialog__footer > .app-dialog__footer > .el-button { min-width: 88px; height: 38px; }
|
||||
|
||||
/* Opted-in detail and editor content reuse the existing compact drawer frame. */
|
||||
:is(.admin-density-dialog, .admin-density-drawer:where(.admin-density-drawer--detail, .admin-density-drawer--editor)) .density-code-block { margin:0; padding:var(--admin-space-3); border:1px solid var(--admin-table-border); border-radius:var(--admin-radius); background:var(--admin-surface); color:var(--cc-text); font:var(--admin-font-caption)/1.65 ui-monospace,SFMono-Regular,Consolas,monospace; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
@@ -187,10 +192,10 @@
|
||||
.admin-density .density-element-tabs > .el-tabs__header .el-tabs__nav .el-tabs__item:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: -2px; }
|
||||
.admin-density .density-tab-icon { display: inline-flex; width: var(--admin-tab-icon-slot); height: var(--admin-tab-icon-slot); flex: 0 0 var(--admin-tab-icon-slot); align-items: center; justify-content: center; font: var(--admin-tab-icon-size)/1 "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", sans-serif; }
|
||||
|
||||
.admin-density-drawer .el-drawer__header { margin-bottom: 0; padding: var(--admin-space-5) var(--admin-space-6); border-bottom: 1px solid var(--cc-border); }
|
||||
.admin-density-drawer .el-drawer__title { font-size: var(--admin-font-title); }
|
||||
.admin-density-drawer .el-drawer__body { padding: var(--admin-space-5) var(--admin-space-6); }
|
||||
.admin-density-drawer .el-drawer__footer { padding: var(--admin-space-5) var(--admin-space-6); border-top: 1px solid var(--cc-border); }
|
||||
.admin-density-drawer > .el-drawer__header { margin-bottom: 0; padding: var(--admin-space-5) var(--admin-space-6); border-bottom: 1px solid var(--cc-border); }
|
||||
.admin-density-drawer > .el-drawer__header > .el-drawer__title { font-size: var(--admin-font-title); }
|
||||
.admin-density-drawer > .el-drawer__body { padding: var(--admin-space-5) var(--admin-space-6); }
|
||||
.admin-density-drawer > .el-drawer__footer { padding: var(--admin-space-5) var(--admin-space-6); border-top: 1px solid var(--cc-border); }
|
||||
|
||||
/* Reusable row action group; no business action names in the shared layer. */
|
||||
.admin-density .data-table .density-actions { --cc-list-action-sibling-margin: 0px; display: flex; align-items: center; gap: var(--cc-row-action-gap, var(--admin-space-2)); }
|
||||
@@ -276,6 +281,8 @@
|
||||
:is(.admin-density, .admin-density-dialog) .density-table--editable .el-input__inner { text-align: left; }
|
||||
:is(.admin-density, .admin-density-dialog) .density-table--editable .el-form-item { margin-bottom: 0; }
|
||||
:is(.admin-density, .admin-density-dialog) .density-table--editable .el-form-item.is-error { margin-bottom: var(--admin-space-5); }
|
||||
/* Cell validation must increase row height instead of covering the next row. */
|
||||
:is(.admin-density, .admin-density-dialog) .density-table--editable .el-form-item__error:not(.el-form-item__error--inline) { position: static; flex-basis: 100%; white-space: normal; }
|
||||
:is(.admin-density, .admin-density-dialog) .density-table--editable :is(.el-input-number, .el-segmented, .el-date-editor) { width: 100%; min-width: 0; }
|
||||
:is(.admin-density, .admin-density-dialog) .density-table-editor > span { flex: 0 0 auto; color: var(--cc-muted); white-space: nowrap; }
|
||||
|
||||
|
||||
@@ -423,8 +423,8 @@ describe('approved Admin density ownership', () => {
|
||||
expect(classesOf(frame!)).toContain('density-confirm-dialog')
|
||||
expect(dialog).toContain('align-center')
|
||||
expect(css).toContain('.admin-density-dialog.density-confirm-dialog.el-dialog:not(.is-fullscreen) { margin: auto; }')
|
||||
expect(css).toContain('.admin-density-dialog.density-confirm-dialog.el-dialog .el-dialog__header { border-bottom: 0; }')
|
||||
expect(css).toContain('.admin-density-dialog.density-confirm-dialog.el-dialog .el-dialog__footer { border-top: 0;')
|
||||
expect(css).toContain('.admin-density-dialog.density-confirm-dialog.el-dialog > .el-dialog__header { border-bottom: 0; }')
|
||||
expect(css).toContain('.admin-density-dialog.density-confirm-dialog.el-dialog > .el-dialog__footer { border-top: 0;')
|
||||
})
|
||||
it('keeps the simplified calendar in two columns with bounded previews and scrollable daily details', () => {
|
||||
const view = parse(read('modules/appointment-calendar/AppointmentCalendarView.vue')).descriptor
|
||||
@@ -1127,7 +1127,9 @@ describe('approved Admin density ownership', () => {
|
||||
expect(source).toContain("'admin-density-dialog--comfortable': dense")
|
||||
expect(source).toContain('width="min(800px, calc(100vw - 32px))"')
|
||||
expect(shared).toContain('.admin-density-dialog.admin-density-dialog--comfortable.el-dialog { --el-component-size: 40px; border-radius: 12px; }')
|
||||
expect(shared).toContain('.admin-density-dialog.admin-density-dialog--comfortable.el-dialog .el-select__wrapper { min-height: 40px; }')
|
||||
expect(shared).toContain('> .el-form-item__content > .el-select > .el-select__wrapper { min-height: 40px; }')
|
||||
expect(shared).not.toContain('.admin-density-dialog.admin-density-dialog--comfortable.el-dialog .el-form-item')
|
||||
expect(shared).not.toContain('.admin-density-dialog.admin-density-dialog--comfortable.el-dialog .el-select__wrapper')
|
||||
})
|
||||
|
||||
it('keeps conditional shared consumers opt-in instead of enabling other menus', () => {
|
||||
|
||||
@@ -448,8 +448,15 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin-workbench .workbench-table.is-page-surface > .data-table > .data-table__toolbar .el-button,
|
||||
.admin-workbench .workbench-toolbar.is-page-surface > .data-table > .data-table__toolbar .el-button {
|
||||
/* Action wrappers and dropdown/button groups are bounded before floating roots. */
|
||||
.admin-workbench :is(.workbench-table, .workbench-toolbar).is-page-surface > .data-table > .data-table__toolbar > .data-table__toolbar-start > .el-button,
|
||||
.admin-workbench :is(.workbench-table, .workbench-toolbar).is-page-surface > .data-table > .data-table__toolbar > .data-table__toolbar-start > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button,
|
||||
.admin-workbench :is(.workbench-table, .workbench-toolbar).is-page-surface > .data-table > .data-table__toolbar > .data-table__toolbar-start > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button,
|
||||
.admin-workbench :is(.workbench-table, .workbench-toolbar).is-page-surface > .data-table > .data-table__toolbar > .data-table__toolbar-start > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button,
|
||||
.admin-workbench :is(.workbench-table, .workbench-toolbar).is-page-surface > .data-table > .data-table__toolbar > .data-table__toolbar-actions > :is(.data-table__business-actions, .data-table__utility-actions) > .el-button,
|
||||
.admin-workbench :is(.workbench-table, .workbench-toolbar).is-page-surface > .data-table > .data-table__toolbar > .data-table__toolbar-actions > :is(.data-table__business-actions, .data-table__utility-actions) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button,
|
||||
.admin-workbench :is(.workbench-table, .workbench-toolbar).is-page-surface > .data-table > .data-table__toolbar > .data-table__toolbar-actions > :is(.data-table__business-actions, .data-table__utility-actions) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button,
|
||||
.admin-workbench :is(.workbench-table, .workbench-toolbar).is-page-surface > .data-table > .data-table__toolbar > .data-table__toolbar-actions > :is(.data-table__business-actions, .data-table__utility-actions) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button {
|
||||
min-height: var(--admin-control-height, 32px);
|
||||
height: var(--admin-control-height, 32px);
|
||||
}
|
||||
@@ -522,7 +529,10 @@
|
||||
}
|
||||
|
||||
.admin-workbench .workbench-table .data-table .data-table__page-button[aria-current='page']:not(:disabled),
|
||||
.admin-workbench .workbench-table .data-table__business-actions .el-button--primary:not(:disabled) {
|
||||
.admin-workbench .workbench-table > .data-table > .data-table__toolbar > .data-table__toolbar-actions > .data-table__business-actions > .el-button--primary:not(:disabled),
|
||||
.admin-workbench .workbench-table > .data-table > .data-table__toolbar > .data-table__toolbar-actions > .data-table__business-actions > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button--primary:not(:disabled),
|
||||
.admin-workbench .workbench-table > .data-table > .data-table__toolbar > .data-table__toolbar-actions > .data-table__business-actions > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button--primary:not(:disabled),
|
||||
.admin-workbench .workbench-table > .data-table > .data-table__toolbar > .data-table__toolbar-actions > .data-table__business-actions > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button--primary:not(:disabled) {
|
||||
box-shadow: var(--workbench-shadow);
|
||||
}
|
||||
|
||||
@@ -600,13 +610,13 @@
|
||||
border-radius: var(--workbench-radius);
|
||||
}
|
||||
|
||||
.admin-workbench.is-dialog.el-dialog .el-dialog__header,
|
||||
.admin-workbench.is-dialog.el-dialog .el-dialog__footer {
|
||||
.admin-workbench.is-dialog.el-dialog > .el-dialog__header,
|
||||
.admin-workbench.is-dialog.el-dialog > .el-dialog__footer {
|
||||
border-color: var(--workbench-border);
|
||||
background: var(--workbench-surface);
|
||||
}
|
||||
|
||||
.admin-workbench.is-dialog.el-dialog .el-dialog__title {
|
||||
.admin-workbench.is-dialog.el-dialog > .el-dialog__header > .el-dialog__title {
|
||||
color: var(--workbench-text);
|
||||
}
|
||||
|
||||
@@ -616,13 +626,13 @@
|
||||
color: var(--workbench-text);
|
||||
}
|
||||
|
||||
.admin-workbench.is-drawer.el-drawer .el-drawer__header,
|
||||
.admin-workbench.is-drawer.el-drawer .el-drawer__footer {
|
||||
.admin-workbench.is-drawer.el-drawer > .el-drawer__header,
|
||||
.admin-workbench.is-drawer.el-drawer > .el-drawer__footer {
|
||||
border-color: var(--workbench-border);
|
||||
background: var(--workbench-surface);
|
||||
}
|
||||
|
||||
.admin-workbench.is-drawer.el-drawer .el-drawer__title {
|
||||
.admin-workbench.is-drawer.el-drawer > .el-drawer__header > .el-drawer__title {
|
||||
color: var(--workbench-text);
|
||||
}
|
||||
|
||||
|
||||
@@ -66,8 +66,10 @@ describe('Admin workbench opt-in styles', () => {
|
||||
for (const selector of rule.selectors) {
|
||||
expect(selector).toMatch(/^\.admin-workbench(?:\s|\.|:|\[|$)/)
|
||||
expect(selector).not.toMatch(/:global|:root/)
|
||||
if (selector.includes('.el-dialog')) expect(selector).toMatch(/^\.admin-workbench\.is-dialog\.el-dialog/)
|
||||
if (selector.includes('.el-drawer')) expect(selector).toMatch(/^\.admin-workbench\.is-drawer\.el-drawer/)
|
||||
// Excluding floating roots from a toolbar is not a selector targeting their chrome.
|
||||
const targetSelector = selector.replaceAll(':not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table))', '')
|
||||
if (targetSelector.includes('.el-dialog')) expect(targetSelector).toMatch(/^\.admin-workbench\.is-dialog\.el-dialog/)
|
||||
if (targetSelector.includes('.el-drawer')) expect(targetSelector).toMatch(/^\.admin-workbench\.is-drawer\.el-drawer/)
|
||||
}
|
||||
})
|
||||
root(source).walkDecls(declaration => {
|
||||
|
||||
Reference in New Issue
Block a user