发布:将已验 TEST 功能更新至既有生产灰度(2026-10-23) #21

Merged
admin merged 100 commits from release/test into main 2026-10-23 10:51:28 +08:00
16 changed files with 622 additions and 860 deletions
Showing only changes of commit 05cf82dbbc - Show all commits
@@ -1,120 +1,69 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
import { scheduleTypes, type ScheduleEvent } from './schedule'
describe('appointment calendar interactions', () => {
it('keeps creation beside the title and record actions beside data notices', () => {
const wrapper = mount(AppointmentCalendarPanel, {
props: { canCreate: true, events: [], notices: ['数据范围不完整'] },
slots: { 'toolbar-actions': '<button>未定时安排</button><button>取消记录</button>' },
global: { stubs: { ElButton: { template: '<button><slot /></button>' } }, directives: { loading: {} } },
})
expect(wrapper.get('.calendar-label').text()).toContain('新增预约')
const actions = wrapper.get('[aria-label="预约记录与数据提示"]')
expect(actions.text()).toContain('未定时安排')
expect(actions.text()).toContain('取消记录')
expect(actions.get('summary').text()).toContain('数据提示')
wrapper.unmount()
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 },
global: { stubs: { ElButton: { template: '<button><slot /></button>' } } } })
describe('four-type appointment calendar', () => {
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)
expect(wrapper.get('[aria-label="安排类型"]').text()).toBe('到港排车还柜计划到仓')
expect(wrapper.findAll('.detail-event')).toHaveLength(4)
expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('4 项安排')
expect(wrapper.findAll('.calendar-day')).toHaveLength(42)
expect(wrapper.text()).not.toMatch(/新增预约|全局待办|需复核|中转|直送/)
})
const render = () => mount(AppointmentCalendarPanel, {
props: { initialDate: '2026-09-24', canCreate: true, events: [
{ key: 'a', type: 'delivery', date: '2026-09-24', time: '09:00-11:00', title: 'ONT8 预约送货', fields: [], canEdit: true, canDelete: true },
] },
global: { stubs: { ElButton: { template: '<button><slot /></button>' } }, directives: { loading: {} } },
})
it('opens a readable appointment without exposing write actions', async () => {
const wrapper = render()
const event = {key:'a',type:'delivery' as const,date:'2026-09-24',time:'09:00',title:'预约',fields:[],canView:true,canEdit:false,canDelete:false}
await wrapper.setProps({events:[event]})
const actions=wrapper.get('.detail-event footer')
expect(actions.text()).toBe('查看预约')
await actions.get('button').trigger('click')
expect(wrapper.emitted('view')?.[0]).toEqual([event])
expect(wrapper.emitted('edit')).toBeUndefined()
wrapper.unmount()
})
it('hides optional references by default and counts only appointments after enabling them', async () => {
const wrapper = render()
await wrapper.setProps({ events: [
{ key: 'a', type: 'delivery', date: '2026-09-24', time: '09:00', title: '收货预约', fields: [] },
{ key: 'b', type: 'arrival', date: '2026-09-24', time: '08:00', title: '到港参考', fields: [] },
{ key: 'c', type: 'trip', date: '2026-09-24', time: '07:00', title: '运输计划', fields: [] },
] })
it('filters locally without requesting another range', async () => {
const wrapper = render([event('arrival'), event('dispatch')])
await wrapper.get('[aria-label="安排类型"] input').setValue(false)
expect(wrapper.findAll('.detail-event')).toHaveLength(1)
const switches = wrapper.get('[aria-label="参考图层"]').findAll('input')
await switches[0]!.setValue(true); await switches[1]!.setValue(true)
expect(wrapper.findAll('.detail-event')).toHaveLength(3)
expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('1 项预约')
expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('2 条参考计划')
await wrapper.get('[aria-label="搜索当前日历"]').setValue('missing')
expect(wrapper.text()).toContain('当天暂无安排')
expect(wrapper.emitted('range')).toBeUndefined()
})
it('selects an already loaded date without requesting data again', async () => {
it('changes the selected date locally and requests one range per mode or period change', async () => {
const wrapper = render()
await wrapper.get('[aria-label="2026-09-25,0 项安排"]').trigger('click')
await wrapper.get('[data-date="2026-10-20"]').trigger('click')
expect(wrapper.emitted('range')).toBeUndefined()
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('当天暂无预约')
})
it('shows an overnight slot on both days without fetching again and excludes midnight ends', async () => {
const wrapper = render()
await wrapper.setProps({ events: [
{ key: 'overnight', type: 'delivery', date: '2026-09-23', endAt: '2026-09-24T01:00:00', time: '23:00–01:00', title: '跨日送仓', fields: [] },
{ key: 'midnight', type: 'delivery', date: '2026-09-23', endAt: '2026-09-24T00:00:00', time: '23:00–00:00', title: '午夜结束', fields: [] },
] })
expect(wrapper.find('[aria-label="2026-09-23,2 项安排"]').exists()).toBe(true)
expect(wrapper.find('[aria-label="2026-09-24,1 项安排"]').exists()).toBe(true)
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('跨日送仓')
expect(wrapper.get('[aria-label="当天安排"]').text()).not.toContain('午夜结束')
expect(wrapper.emitted('range')).toBeUndefined()
})
it('requests exactly one range for a view switch and navigates seven days', async () => {
const wrapper = render()
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('2026-10-20')
await wrapper.get('[aria-label="预约视图"]').findAll('button')[1]!.trigger('click')
expect(wrapper.emitted('range')).toEqual([[{ from: '2026-09-21', to: '2026-09-28' }]])
expect(wrapper.emitted('range')).toEqual([[{ from: '2026-10-19', to: '2026-10-26' }]])
await wrapper.get('[aria-label="下一周"]').trigger('click')
expect(wrapper.emitted('range')?.at(-1)).toEqual([{ from: '2026-09-28', to: '2026-10-05' }])
expect(wrapper.emitted('range')?.at(-1)).toEqual([{ from: '2026-10-26', to: '2026-11-02' }])
})
it('keeps busy days to three previews in both views and exposes the full count', async () => {
const wrapper = render()
await wrapper.setProps({ events: Array.from({ length: 233 }, (_, index) => ({
key: `arrival-${index}`, type: 'arrival' as const, date: '2026-09-24', time: '09:00',
title: `船舶 ${index}`, fields: [],
})) })
await wrapper.get('[aria-label="参考图层"] input').setValue(true)
for (const view of ['month', 'week']) {
if (view === 'week') await wrapper.get('[aria-label="预约视图"]').findAll('button')[1]!.trigger('click')
const day = wrapper.get('[aria-label="2026-09-24,233 项安排"]')
expect(day.findAll('.event-chip')).toHaveLength(3)
expect(day.attributes('title')).toContain('共 233 条安排')
expect(day.get('.event-more').text()).toBe('还有 230 条')
expect(wrapper.findAll('.detail-event')).toHaveLength(233)
}
it('limits previews to three while retaining every event in the daily details', () => {
const wrapper = render(Array.from({ length: 23 }, (_, index) => event('arrival', index)))
expect(wrapper.get('[data-date="2026-10-19"]').findAll('.event-chip')).toHaveLength(3)
expect(wrapper.get('.event-more').text()).toBe('还有 20 条')
expect(wrapper.findAll('.detail-event')).toHaveLength(23)
})
it('uses the selected day for creation and emits edit/delete only from detail actions', async () => {
const wrapper = render()
await wrapper.findAll('button').find(button => button.text() === '+ 新增预约')!.trigger('click')
expect(wrapper.emitted('create')).toEqual([['2026-09-24']])
await wrapper.findAll('button').find(button => button.text() === '编辑')!.trigger('click')
await wrapper.findAll('button').find(button => button.text() === '取消预约')!.trigger('click')
expect(wrapper.emitted('edit')?.[0]?.[0]).toMatchObject({ key: 'a' })
expect(wrapper.emitted('delete')?.[0]?.[0]).toMatchObject({ key: 'a' })
it('keeps undated records outside date cells and exposes them explicitly', async () => {
const wrapper = render([{ ...event('warehouse'), date: null }])
expect(wrapper.findAll('.event-chip')).toHaveLength(0)
await wrapper.get('.undated-button').trigger('click')
expect(wrapper.findAll('.detail-event')).toHaveLength(1)
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('日期未定')
})
it('renders many inbound codes outside the two-column fields with only three previews', async () => {
it('retains business links and exposes editing only with authoritative eligibility', async () => {
const row = { ...event('warehouse'), sourcePath: '/container-loading/overseas/1', canEdit: true }
const wrapper = render([row])
await wrapper.get('.detail-event footer').findAll('button')[0]!.trigger('click')
await wrapper.get('.detail-event footer').findAll('button')[1]!.trigger('click')
expect(wrapper.emitted('open')).toEqual([[row]])
expect(wrapper.emitted('edit')).toEqual([[row]])
await wrapper.setProps({ events: [{ ...row, canEdit: false, sourcePath: undefined }] })
expect(wrapper.find('.detail-event footer').exists()).toBe(false)
})
it('distinguishes loading and partial failure from a complete empty day', async () => {
const wrapper = render()
await wrapper.setProps({ events: [{
key: 'many', type: 'delivery', date: '2026-09-24', time: '09:00', title: 'ONT8 预约送仓',
fields: [{ label: '货物信息', value: '20 票 / 12 个客户' }],
inboundCodes: Array.from({ length: 20 }, (_, index) => `WT-INBOUND-${index}`), canEdit: true,
}] })
const card = wrapper.get('.detail-event')
expect(card.get('dl').text()).not.toContain('WT-INBOUND')
expect(card.findAll('.inbound-codes li')).toHaveLength(3)
expect(card.get('.inbound-codes').text()).toContain('20 个')
expect(card.get('footer').text()).toContain('编辑')
await wrapper.setProps({ loading: true })
expect(wrapper.text()).toContain('正在读取安排')
expect(wrapper.text()).not.toContain('当天暂无安排')
await wrapper.setProps({ loading: false, incomplete: true })
expect(wrapper.text()).toContain('部分数据不可用')
expect(wrapper.text()).not.toContain('当天暂无安排')
})
})
@@ -1,82 +1,40 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, ref } from 'vue'
import AppointmentInboundCodes from './AppointmentInboundCodes.vue'
import { addDays, calendarDates, calendarRange, dateKey, moveCalendar, occursOnDate, type CalendarMode } from './calendar'
import { calendarDates, calendarRange, dateKey, moveCalendar, type CalendarMode } from './calendar'
import { scheduleLabels, scheduleTypes, type ScheduleEvent, type ScheduleType } from './schedule'
export interface CalendarEvent {
key: string
date: string
endAt?: string
time: string
type: 'arrival' | 'delivery' | 'pickup' | 'trip'
title: string
subtitle?: string
fields: Array<{ label: string; value?: string | null }>
inboundCodes?: Array<string | null | undefined>
canEdit?: boolean
canView?: boolean
canDelete?: boolean
sourcePath?: string
}
const props = withDefaults(defineProps<{
notices?: string[]
events: CalendarEvent[]
loading?: boolean
incomplete?: boolean
canCreate?: boolean
initialDate?: string
}>(), { notices: () => [], loading: false, incomplete: false, canCreate: false, initialDate: '' })
const emit = defineEmits<{
layers: [layers: { arrivals: boolean; trips: boolean }]
range: [range: { from: string; to: string }]
create: [date: string]
edit: [event: CalendarEvent]
view: [event: CalendarEvent]
delete: [event: CalendarEvent]
open: [event: CalendarEvent]
}>()
const showArrivals = ref(false), showTrips = ref(false)
function updateLayers() { emit('layers', { arrivals: showArrivals.value, trips: showTrips.value }) }
const visibleEvents = computed(() => props.events.filter(event => (event.type !== 'arrival' || showArrivals.value) && (event.type !== 'trip' || showTrips.value)))
const selectedDate = ref(props.initialDate || dateKey(new Date()))
const mode = ref<CalendarMode>('month')
const props = withDefaults(defineProps<{ events: ScheduleEvent[]; loading?: boolean; incomplete?: boolean; initialDate?: string; initialMode?: CalendarMode }>(),
{ loading: false, incomplete: false, initialDate: '', initialMode: 'month' })
const emit = defineEmits<{ range: [range: { from: string; to: string }]; select: [date: string, mode: CalendarMode]; open: [event: ScheduleEvent]; edit: [event: ScheduleEvent] }>()
const selectedDate = ref(props.initialDate || dateKey(new Date())), mode = ref<CalendarMode>(props.initialMode)
const types = ref<ScheduleType[]>([...scheduleTypes]), keyword = ref(''), undated = ref(false)
const dates = computed(() => calendarDates(selectedDate.value, mode.value))
const visibleEvents = computed(() => props.events.filter(event => types.value.includes(event.type) &&
`${event.title} ${event.fields.map(field => field.value || '').join(' ')}`.toLowerCase().includes(keyword.value.trim().toLowerCase())))
const grouped = computed(() => {
const result = new Map<string, CalendarEvent[]>()
const result = new Map<string, ScheduleEvent[]>()
for (const event of visibleEvents.value) {
for (const date of dates.value) {
if (!occursOnDate(event.date, event.endAt, date)) continue
const group = result.get(date) || []
group.push(event)
result.set(date, group)
}
const date = event.date || 'undated'
const rows = result.get(date) || []; rows.push(event); result.set(date, rows)
}
for (const group of result.values()) group.sort((a, b) => a.time.localeCompare(b.time) || a.key.localeCompare(b.key))
for (const rows of result.values()) rows.sort((a, b) => a.time.localeCompare(b.time) || a.key.localeCompare(b.key))
return result
})
const selectedEvents = computed(() => grouped.value.get(selectedDate.value) || [])
const appointmentCount = computed(() => selectedEvents.value.filter(event => event.type === 'delivery' || event.type === 'pickup').length)
const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
const eventTypeLabels = { arrival: '船舶到港', delivery: '预约送仓', pickup: '预约提柜', trip: '用车计划' }
const heading = computed(() => mode.value === 'month'
? `${selectedDate.value.slice(0, 4)}年${Number(selectedDate.value.slice(5, 7))}月`
: `${dates.value[0]} — ${addDays(calendarRange(selectedDate.value, mode.value).to, -1)}`)
const selectedEvents = computed(() => grouped.value.get(undated.value ? 'undated' : selectedDate.value) || [])
const heading = computed(() => mode.value === 'month' ? `${selectedDate.value.slice(0, 4)}年${Number(selectedDate.value.slice(5, 7))}月` : `${dates.value[0]} — ${dates.value.at(-1)}`)
function select(date: string) {
const before = calendarRange(selectedDate.value, mode.value)
selectedDate.value = date
selectedDate.value = date; undated.value = false
const after = calendarRange(date, mode.value)
emit('select', date, mode.value)
if (before.from !== after.from || before.to !== after.to) emit('range', after)
}
function changeMode(next: CalendarMode) {
if (mode.value === next) return
mode.value = next
emit('range', calendarRange(selectedDate.value, next))
mode.value = next; emit('select', selectedDate.value, next); emit('range', calendarRange(selectedDate.value, next))
}
defineExpose({ selectedDate, mode })
</script>
<template>
<section class="appointment-calendar admin-density" :aria-busy="loading">
<div class="calendar-main">
@@ -84,126 +42,75 @@ defineExpose({ selectedDate, mode })
<div class="calendar-label">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="5" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.8"/><path d="M8 3v4M16 3v4M3 10h18M8 15h3M14 15h3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
<strong>预约日历</strong>
<el-button v-if="canCreate" type="primary" :disabled="loading" @click="emit('create', selectedDate)">+ 新增预约</el-button>
</div>
<div class="calendar-controls">
<div class="view-toggle" role="group" aria-label="预约视图">
<button type="button" :aria-pressed="mode === 'month'" @click="changeMode('month')">月</button>
<button type="button" :aria-pressed="mode === 'week'" @click="changeMode('week')">周</button>
</div>
<div class="view-toggle" role="group" aria-label="预约视图"><button type="button" :aria-pressed="mode === 'month'" @click="changeMode('month')">月</button><button type="button" :aria-pressed="mode === 'week'" @click="changeMode('week')">周</button></div>
<button class="nav-button" type="button" :aria-label="mode === 'month' ? '上个月' : '上一周'" @click="select(moveCalendar(selectedDate, mode, -1))">‹</button>
<strong class="calendar-period">{{ heading }}</strong>
<button class="nav-button" type="button" :aria-label="mode === 'month' ? '下个月' : '下一周'" @click="select(moveCalendar(selectedDate, mode, 1))">›</button>
<el-button @click="select(dateKey(new Date()))">今天</el-button>
</div>
<div class="calendar-record-actions" role="group" aria-label="预约记录与数据提示">
<slot name="toolbar-actions" />
<details v-if="notices.length" class="calendar-data-notices"><summary>数据提示 · {{ notices.length }}</summary><div class="calendar-notice-content" role="status"><p v-for="notice in notices" :key="notice">{{ notice }}</p><slot name="notice-actions" /></div></details>
</div>
</header>
<div class="calendar-options">
<div class="calendar-legend"><span v-if="showArrivals" class="legend arrival">船舶到港</span><span class="legend delivery">预约送仓</span><span class="legend pickup">预约提柜</span><span v-if="showTrips" class="legend trip">用车计划</span></div>
<div class="calendar-layers" role="group" aria-label="参考图层"><label><input v-model="showArrivals" type="checkbox" @change="updateLayers">显示到港参考</label><label><input v-model="showTrips" type="checkbox" @change="updateLayers">显示用车计划</label></div>
<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 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 weekdays" :key="day">{{ day }}</span></div>
<div v-loading="loading" class="calendar-grid" :class="{ 'week-grid': mode === 'week' }">
<button v-for="date in dates" :key="date" type="button" class="calendar-day"
:class="{ selected: selectedDate === date, outside: mode === 'month' && date.slice(0, 7) !== selectedDate.slice(0, 7) }"
:title="`${date},${incomplete ? '已加载' : '共'} ${(grouped.get(date) || []).length} 条安排${incomplete ? '(当前范围不完整)' : ''}`" :aria-label="`${date},${(grouped.get(date) || []).length} 项安排${incomplete ? '(当前范围不完整)' : ''}`" :aria-pressed="selectedDate === date" @click="select(date)">
<span class="day-number">{{ mode === 'week' ? `${Number(date.slice(5, 7))}月` : '' }}{{ Number(date.slice(8)) }}</span>
<span class="day-events">
<span v-for="event in (grouped.get(date) || []).slice(0, 3)" :key="event.key" class="event-chip" :class="event.type" :title="`${event.time} ${event.title} · 当天${incomplete ? '已加载' : '共'} ${(grouped.get(date) || []).length} 条安排${incomplete ? '(当前范围不完整)' : ''}`">
{{ event.title }}<small v-if="mode === 'week'">{{ event.time }}</small>
</span>
<span v-if="(grouped.get(date) || []).length > 3" class="event-more">还有 {{ (grouped.get(date) || []).length - 3 }} 条</span>
</span>
<div class="weekdays"><span v-for="day in ['周一', '周二', '周三', '周四', '周五', '周六', '周日']" :key="day">{{ day }}</span></div>
<div class="calendar-grid" :class="{ 'week-grid': mode === 'week' }">
<button v-for="date in dates" :key="date" type="button" class="calendar-day" :data-date="date"
:class="{ selected: !undated && selectedDate === date, outside: mode === 'month' && date.slice(0, 7) !== selectedDate.slice(0, 7) }"
:aria-label="`${date},${loading ? '加载中' : `${(grouped.get(date) || []).length} 项安排`}`" :aria-pressed="!undated && selectedDate === date" @click="select(date)">
<span class="day-number">{{ Number(date.slice(8)) }}</span>
<span v-if="!loading" class="day-events"><span v-for="event in (grouped.get(date) || []).slice(0, 3)" :key="event.key" class="event-chip" :class="event.type" :title="event.title">{{ event.title }}</span><span v-if="(grouped.get(date) || []).length > 3" class="event-more">还有 {{ (grouped.get(date) || []).length - 3 }} 条</span></span>
</button>
</div>
</div>
<aside class="calendar-detail" aria-label="当天安排">
<header><strong>{{ selectedDate }}</strong><p>{{ incomplete ? '当天已加载' : '当天共有' }} <b>{{ appointmentCount }}</b> 项预约<span v-if="selectedEvents.length > appointmentCount">,{{ selectedEvents.length - appointmentCount }} 条参考计划</span><span v-if="incomplete">(当前范围不完整)</span></p></header>
<header><strong>{{ undated ? '未定日期' : selectedDate }}</strong><p v-if="loading">正在读取安排…</p><p v-else>{{ incomplete ? '当前已加载' : undated ? '共有' : '当天共有' }} <b>{{ selectedEvents.length }}</b> 项安排<span v-if="incomplete">(部分数据不可用)</span></p></header>
<div class="detail-events" aria-live="polite">
<p v-if="!selectedEvents.length && !loading" class="empty-day">{{ incomplete ? '当前范围内暂无预约;请按上方提示核对缺失资料或失败来源' : '当天暂无预约' }}</p>
<p v-if="!selectedEvents.length && !loading" class="empty-day">{{ incomplete ? '已加载数据中没有安排,请先处理上方数据提示。' : undated ? '没有未定日期的安排' : '当天暂无安排' }}</p>
<article v-for="event in selectedEvents" :key="event.key" class="detail-event">
<div class="event-heading"><span class="event-type" :class="event.type">{{ eventTypeLabels[event.type] }}</span><span>{{ event.time || '—' }}</span></div>
<div class="event-heading"><span class="event-type" :class="event.type">{{ scheduleLabels[event.type] }}</span><span>{{ event.time || (event.date ? '日期计划' : '日期未定') }}</span></div>
<h3>{{ event.title }}</h3><p v-if="event.subtitle" class="event-subtitle">{{ event.subtitle }}</p>
<dl><div v-for="field in event.fields" :key="field.label" :class="{ 'wide-field': ['系统单号', '完整时段', '预约备注'].includes(field.label) }"><dt>{{ field.label }}</dt><dd>{{ field.value || '—' }}</dd></div></dl>
<AppointmentInboundCodes v-if="event.inboundCodes" :codes="event.inboundCodes" />
<footer v-if="event.canView || event.canEdit || event.canDelete || event.sourcePath" class="density-actions">
<el-button v-if="event.canView" class="density-action" :disabled="loading" @click="emit('view', event)">查看预约</el-button>
<el-button v-if="event.sourcePath" class="density-action" :disabled="loading" @click="emit('open', event)">查看来源</el-button>
<el-button v-if="event.canEdit" class="density-action" :disabled="loading" @click="emit('edit', event)">编辑</el-button>
<el-button v-if="event.canDelete" class="density-action" data-tone="danger" :disabled="loading" @click="emit('delete', event)">取消预约</el-button>
</footer>
<dl><div v-for="field in event.fields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value || '—' }}</dd></div></dl>
<footer v-if="event.sourcePath || event.canEdit"><el-button v-if="event.sourcePath" link type="primary" :disabled="loading" @click="emit('open', event)">{{ event.type === 'dispatch' ? '查看排车' : '查看货柜' }}</el-button><el-button v-if="event.canEdit" link type="primary" :disabled="loading" @click="emit('edit', event)">调整计划到仓</el-button></footer>
</article>
</div>
</aside>
</section>
</template>
<style scoped>
.calendar-data-notices { position: relative; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
.calendar-data-notices summary { cursor: pointer; padding: var(--admin-space-2) var(--admin-space-3); }
.calendar-notice-content { position: absolute; top: 100%; right: 0; z-index: 5; width: 320px; padding: var(--admin-space-5); border: 1px solid var(--admin-table-border); border-radius: var(--admin-radius); background: var(--admin-surface); box-shadow: var(--el-box-shadow-light); }
.calendar-notice-content p { margin: 0; line-height: 1.6; }
.calendar-notice-content p + p { margin-top: var(--admin-space-3); }
.appointment-calendar { display: grid; align-items: start; grid-template-columns: minmax(0, 1fr) 360px; gap: var(--admin-space-5); color: #344054; }
.calendar-main, .calendar-detail { background: #fff; border: 1px solid #e4e9f2; border-radius: 10px; overflow: hidden; }
.appointment-calendar { display: grid; align-items: start; grid-template-columns: minmax(0, 1fr) 360px; gap: var(--admin-space-5); color: var(--el-text-color-regular); }
.calendar-main, .calendar-detail { background: var(--admin-surface); border: 1px solid #e4e9f2; border-radius: 10px; overflow: hidden; }
.calendar-toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: var(--admin-space-3); padding: var(--admin-space-5); border-bottom: 1px solid #edf0f6; }
.calendar-label, .calendar-controls { display: flex; align-items: center; gap: 10px; }
.calendar-record-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
.calendar-record-actions :deep(.el-button + .el-button) { margin-left: 0; }
.calendar-label > svg { color: #617ce9; }
.calendar-label > strong { font-size: var(--admin-font-section); }
.calendar-options { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--admin-space-3); padding: var(--admin-space-3) var(--admin-space-5); border-bottom: 1px solid var(--admin-table-border); }
.calendar-legend, .calendar-layers { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-5); }
.calendar-layers label { display: inline-flex; align-items: center; gap: var(--admin-space-1); cursor: pointer; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
.calendar-layers input { margin: 0; accent-color: var(--el-color-primary); }
.legend { font-size: 12px; white-space: nowrap; }
.legend::before { content: ''; display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 5px; background: #4b78e8; }
.legend.delivery::before { background: #27a87a; }
.legend.pickup::before { background: #c17a20; }
.legend.trip::before { background: #8562b0; }
.calendar-label, .calendar-controls { display: flex; align-items: center; gap: var(--admin-space-3); }
.calendar-label > svg { color: #617ce9; }.calendar-label > strong { font-size: var(--admin-font-section); }
.calendar-options, .calendar-layers { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
.calendar-options { padding: var(--admin-space-3) var(--admin-space-5); border-bottom: 1px solid #edf0f6; }
.legend { display: inline-flex; align-items: center; gap: var(--admin-space-1); font-size: var(--admin-font-caption); cursor: pointer; color: var(--event-fg); }.legend input { accent-color: var(--event-fg); }
.calendar-search { margin-left: auto; }.calendar-search input { box-sizing: border-box; width: 190px; min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); padding: 0 var(--admin-space-3); font: inherit; }
.view-toggle { display: flex; border: 1px solid #dbe3ee; border-radius: 6px; padding: 2px; background: #f7f9fc; }
.view-toggle button, .nav-button { border: 0; border-radius: 5px; background: transparent; cursor: pointer; color: #64748b; min-width: 30px; height: 30px; }
.view-toggle button[aria-pressed='true'] { background: #fff; color: #4167d8; font-weight: 700; }
.nav-button { border: 1px solid #dbe3ee; font-size: 22px; }
.calendar-period { font-size: 14px; white-space: nowrap; }
.weekdays, .calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }
.weekdays { text-align: center; color: #8491a5; background: #f8faff; padding: var(--admin-space-3) var(--admin-space-5); font-size: var(--admin-font-caption); }
.view-toggle button, .nav-button, .undated-button { border: 0; border-radius: var(--admin-radius); background: transparent; cursor: pointer; color: var(--el-text-color-regular); min-width: var(--admin-control-height); min-height: var(--admin-control-height); font: inherit; }
.view-toggle button[aria-pressed='true'], .undated-button[aria-pressed='true'] { background: #fff; color: #4167d8; font-weight: 600; }.nav-button { border: 1px solid #dbe3ee; font-size: 22px; }
.calendar-period { font-size: var(--admin-font-section); white-space: nowrap; }
.weekdays, .calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }.weekdays { text-align: center; color: #8491a5; background: #f8faff; padding: var(--admin-space-3); font-size: var(--admin-font-caption); }
.calendar-grid { gap: var(--admin-space-2); padding: var(--admin-space-3); background: #fbfcff; }
.calendar-day { display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; height: 112px; min-width: 0; padding: var(--admin-space-2); border: 1px solid #e5eaf2; border-radius: 8px; background: #fff; text-align: left; cursor: pointer; color: inherit; font: inherit; overflow: hidden; }
.calendar-day.outside { background: #f7f8fa; color: #98a2b3; }
.calendar-day:hover { border-color: #bac8ed; background: #f8faff; }
.calendar-day.selected { border-color: #617ce9; background: #f5f7ff; }
.calendar-day:focus-visible, .nav-button:focus-visible, .view-toggle button:focus-visible { outline: 2px solid #4167d8; outline-offset: 2px; }
.day-number { display: block; font-size: 12px; font-weight: 600; }
.day-events { display: flex; flex-direction: column; gap: 2px; margin-top: var(--admin-space-1); }
.event-chip { display: block; padding: 1px 6px; line-height: 16px; border-radius: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.arrival { --event-bg: #edf3ff; --event-fg: #3765d4; }
.delivery { --event-bg: #e8f8f1; --event-fg: #18845f; }
.pickup { --event-bg: #fff4e3; --event-fg: #995a10; }
.trip { --event-bg: #f2edfa; --event-fg: #725199; }
.event-chip, .event-type { background: var(--event-bg); color: var(--event-fg); }
.event-chip small { display: block; margin-top: 2px; font-size: 11px; opacity: .8; }
.event-more { padding: 1px 6px; line-height: 16px; }
.event-more, .empty-day { color: #667085; font-size: 12px; }
.week-grid .calendar-day { height: 220px; }
.calendar-detail { align-self: start; }
.calendar-detail > header { padding: var(--admin-space-5); border-bottom: 1px solid #edf0f6; }
.calendar-detail > header > strong { font-size: var(--admin-font-title); }
.calendar-detail header p { margin: var(--admin-space-1) 0 0; font-size: var(--admin-font-caption); color: #8491a5; }
.calendar-detail header b { color: #617ce9; font-size: var(--admin-font-title); }
.detail-events { padding: var(--admin-space-5); max-height: 660px; overflow-y: auto; }
.calendar-day { display: flex; flex-direction: column; align-items: stretch; height: 146px; min-width: 0; padding: var(--admin-space-3); border: 1px solid #e5eaf2; border-radius: 8px; background: #fff; text-align: left; cursor: pointer; color: inherit; font: inherit; }
.calendar-day.outside { background: #f7f8fa; color: #98a2b3; }.calendar-day:hover { border-color: #bac8ed; }.calendar-day.selected { border-color: #617ce9; background: #f5f7ff; }
button:focus-visible, input:focus-visible { outline: 2px solid #4167d8; outline-offset: 2px; }
.day-number { font-size: var(--admin-font-caption); font-weight: 600; }.day-events { display: flex; flex-direction: column; gap: var(--admin-space-1); margin-top: var(--admin-space-2); }
.event-chip { display: block; padding: 2px 6px; line-height: 18px; border-radius: var(--admin-radius); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--admin-font-caption); }
.arrival { --event-bg: #edf3ff; --event-fg: #3765d4; }.warehouse { --event-bg: #e8f8f1; --event-fg: #18845f; }.return { --event-bg: #fff4e3; --event-fg: #995a10; }.dispatch { --event-bg: #f2edfa; --event-fg: #725199; }
.event-chip, .event-type { background: var(--event-bg); color: var(--event-fg); }.event-more, .empty-day { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.week-grid .calendar-day { height: 270px; }.calendar-detail > header { padding: var(--admin-space-5); border-bottom: 1px solid #edf0f6; }.calendar-detail > header > strong { font-size: var(--admin-font-title); }
.calendar-detail header p { margin: var(--admin-space-2) 0 0; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }.calendar-detail header b { color: #617ce9; }
.detail-events { padding: var(--admin-space-5); max-height: calc(100dvh - 260px); overflow-y: auto; }
.detail-event + .detail-event { border-top: 1px solid #e4e9f2; margin-top: var(--admin-space-5); padding-top: var(--admin-space-5); }
.event-heading { display: flex; justify-content: space-between; align-items: center; gap: 10px; font-size: 12px; }
.event-type { padding: 5px 8px; border-radius: 4px; font-weight: 600; }
.detail-event h3 { font-size: var(--admin-font-section); margin: var(--admin-space-3) 0; overflow-wrap: anywhere; }
.event-subtitle { color: #667085; font-size: 13px; overflow-wrap: anywhere; }
.detail-event dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3) var(--admin-space-5); margin: var(--admin-space-3) 0; }
.detail-event .wide-field { grid-column: 1 / -1; }
.detail-event dt { font-size: 12px; color: #8491a5; }
.detail-event dd { margin: 4px 0 0; font-size: 13px; overflow-wrap: anywhere; }
.detail-event footer { display: flex; justify-content: flex-end; border-top: 1px solid #edf0f6; padding-top: 10px; }
.event-heading { display: flex; justify-content: space-between; align-items: center; gap: var(--admin-space-3); font-size: var(--admin-font-caption); }.event-type { padding: var(--admin-space-2) var(--admin-space-3); border-radius: var(--admin-radius); }
.detail-event h3 { font-size: var(--admin-font-section); margin: var(--admin-space-3) 0; overflow-wrap: anywhere; }.event-subtitle { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); overflow-wrap: anywhere; }
.detail-event dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3) var(--admin-space-5); margin: var(--admin-space-3) 0; }.detail-event dt { font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }.detail-event dd { margin: var(--admin-space-1) 0 0; font-size: var(--admin-font-body); overflow-wrap: anywhere; }
.detail-event footer { display: flex; justify-content: flex-end; gap: var(--admin-space-3); border-top: 1px solid #edf0f6; padding-top: var(--admin-space-3); }.detail-event footer :deep(.el-button + .el-button) { margin-left: 0; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
@media (max-width: 1100px) { .appointment-calendar { grid-template-columns: minmax(0, 1fr); } }
</style>
@@ -1,235 +1,118 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { inject, onBeforeUnmount } from 'vue'
import { ElDialog } from 'element-plus'
import type { LeaveGuard } from './useWorkDraft'
import { onBeforeRouteLeave } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { ApiResponseError } from '@/shared/api/http'
import AppointmentCalendarView from './AppointmentCalendarView.vue'
import { getWorkCalendar, getWorkItems, getWorkSummary } from './workApi'
import { listArrivalCalendar } from './api'
import { workCalendar, workItem, workSummary, followAction } from './work.test-support'
const routing = vi.hoisted(() => ({ query: {} as Record<string, string>, push: vi.fn(), replace: vi.fn(), canReadSource: true, allowLeave: true }))
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type WarehouseArrival } from './scheduleApi'
import { listTripCalendar } 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() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission !== 'platform:overseas-warehouse:last-mile-order:list' || routing.canReadSource }) }))
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkCalendar: vi.fn(), getWorkItems: vi.fn(), getWorkSummary: vi.fn() }))
vi.mock('./api', async original => ({ ...await original<typeof import('./api')>(), listArrivalCalendar: vi.fn() }))
function render(realDialog = false) {
return mount(AppointmentCalendarView, { attachTo: document.body, global: { components: { ElDialog }, directives: { loading: {} }, stubs: {
teleport: true,
ElTooltip: { template: '<slot />' },
ElDialog: realDialog ? ElDialog : { name: 'ElDialog', props: ['modelValue', 'title', 'fullscreen', 'beforeClose', 'closeOnClickModal', 'closeOnPressEscape'], emits: ['update:modelValue'], template: '<div v-if="modelValue" class="el-dialog" :class="{ \'is-fullscreen\': fullscreen }"><button aria-label="关闭工作面板" @click="beforeClose(() => $emit(\'update:modelValue\', false))">关闭</button><slot name="header" /><slot /><slot name="footer" /></div>' },
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => !routing.denied.has(permission) }) }))
vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArrivals: vi.fn(), saveWarehouseArrival: vi.fn() }))
vi.mock('./tripCalendar', async original => ({ ...await original<typeof import('./tripCalendar')>(), listTripCalendar: vi.fn() }))
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 }
export function renderSchedule() {
return mount(AppointmentCalendarView, { global: { stubs: {
AdminPage: { template: '<main><slot /></main>' },
ElButton: { props: ['type'], template: '<button type="button" :data-variant="type"><slot /></button>' },
WorkCalendarPanel: { name: 'WorkCalendarPanel', props: ['anchor', 'mode', 'flow', 'types', 'calendar', 'failed', 'reference', 'selectedDate', 'selectedType'], template: '<div data-calendar><slot name="inbox" /></div>' },
WorkDetailPanel: { name: 'WorkDetailPanel', props: ['itemKey', 'initialAction', 'canLeave'], setup() {
const register = inject<(guard: LeaveGuard) => () => void>('appointment-work-leave-guard')!
const unregister = register(async () => routing.allowLeave)
onBeforeUnmount(unregister)
}, template: '<div data-detail>{{ itemKey }}</div>' },
SourceAppointmentPanel: { name: 'SourceAppointmentPanel', props: ['source'], template: '<div data-cargo />' },
LoadErrorState: { props: ['title', 'message'], template: '<section role="alert">{{ title }} {{ message }}<slot name="details" /><button @click="$emit(\'retry\')">重试</button></section>' },
ApiErrorMeta: true,
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', props: ['events', 'loading', 'incomplete', 'initialDate', 'initialMode'], template: '<section data-calendar />' },
AppDialog: { name: 'AppDialog', props: ['modelValue', 'beforeClose', 'confirmDisabled', 'confirmLoading'], emits: ['confirm'], template: '<section v-if="modelValue" data-dialog><slot /><button data-save :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button><button data-close @click="beforeClose(() => {})">关闭</button></section>' },
WorkDetailPanel: true, SourceAppointmentPanel: true, ApiErrorMeta: true,
LoadErrorState: { props: ['title', 'message'], emits: ['retry'], template: '<section role="alert">{{ title }} {{ message }}<slot name="details" /><button data-retry @click="$emit(\'retry\')">重试</button></section>' },
} } })
}
describe('appointment work calendar', () => {
beforeEach(() => {
vi.clearAllMocks(); routing.query = { workAnchor: '2026-10-15' }; routing.canReadSource = true; routing.allowLeave = true
routing.replace.mockResolvedValue(undefined); routing.push.mockResolvedValue(undefined)
vi.mocked(getWorkCalendar).mockResolvedValue(workCalendar)
vi.mocked(getWorkSummary).mockResolvedValue(workSummary)
vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem()], page: 1, size: 8, total: 1, pages: 1 })
vi.mocked(listArrivalCalendar).mockResolvedValue([])
})
it('retains the old batch and page when a resolved later batch continues into its vehicle work', async () => {
routing.query = { workAnchor: '2026-10-15', workScope: 'global', workView: 'UNDATED', workPage: '3', workItem: 'old-batch-work' }
const wrapper = render(); await flushPromises()
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('open', 'later-batch-work'); await flushPromises()
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('later-batch-work')
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('open', 'later-vehicle-work', followAction); await flushPromises()
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('later-vehicle-work')
await wrapper.get('.panel-nav button').trigger('click'); await flushPromises()
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('later-batch-work')
await wrapper.get('.panel-nav button').trigger('click'); await flushPromises()
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('old-batch-work')
await wrapper.get('.panel-nav button').trigger('click'); await flushPromises()
expect(getWorkItems).toHaveBeenLastCalledWith(expect.objectContaining({ view: 'UNDATED', page: 3 }), expect.any(AbortSignal))
beforeEach(() => {
vi.clearAllMocks(); routing.query = { date: '2026-10-19' }; routing.denied.clear()
vi.mocked(getCalendarSources).mockResolvedValue({ containers: [{ containerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', vesselName: null, voyageNo: null, destinationPortName: null, destinationWarehouseCode: null, arrivalAt: '2026-10-19T10:00:00', arrivalType: 'ACTUAL' }], emptyReturns: [], emptyReturnsReadable: true })
vi.mocked(getWarehouseArrivals).mockResolvedValue([warehouse])
vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] })
vi.mocked(saveWarehouseArrival).mockResolvedValue({})
})
describe('simplified appointment entry', () => {
it('keeps unsaved input when the operator declines to leave', async () => {
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel')
const wrapper = renderSchedule(); await flushPromises()
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
await wrapper.get('input').setValue('2026-10-22T09:30')
await wrapper.get('[data-close]').trigger('click'); await flushPromises()
expect(ElMessageBox.confirm).toHaveBeenCalled()
expect(wrapper.find('[data-dialog]').exists()).toBe(true)
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30')
wrapper.unmount()
})
it('opens the real work calendar by default and converts its half-open helper range to the inclusive API contract', async () => {
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('预约工作日历')
expect(wrapper.get('[data-calendar] .work-inbox').attributes('aria-label')).toBe('全局待办')
expect(getWorkCalendar).toHaveBeenCalledWith({ from: '2026-09-28', to: '2026-11-08', flow: undefined, types: ['PICKUP', 'WAREHOUSE_RECEIPT', 'DELIVERY', 'TRANSPORT', 'EMPTY_RETURN'] }, expect.any(AbortSignal))
expect(getWorkSummary).toHaveBeenCalledTimes(1); expect(getWorkItems).not.toHaveBeenCalled(); expect(listArrivalCalendar).not.toHaveBeenCalled()
it('loads original sources and real trips without loading work summaries or generated work items', async () => {
const wrapper = renderSchedule(); await flushPromises()
expect(getWarehouseArrivals).toHaveBeenCalledWith({ from: '2026-09-28', to: '2026-11-09' }, ['1'], expect.any(AbortSignal))
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
expect(panel.props('events').map((event: ScheduleEvent) => [event.type, event.date])).toEqual([['arrival', '2026-10-19'], ['warehouse', '2026-10-21']])
expect(wrapper.text()).not.toMatch(/全局待办|跟进|复核/)
wrapper.unmount()
})
it('keeps day/type selection and pagination context in the overlay and clears selection on close', async () => {
vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem()], page: 1, size: 8, total: 30, pages: 4 })
const wrapper = render(); await flushPromises()
const calendar = wrapper.getComponent({ name: 'WorkCalendarPanel' })
calendar.vm.$emit('day', '2026-10-15', 'PICKUP'); await flushPromises()
expect(calendar.props()).toMatchObject({ selectedDate: '2026-10-15', selectedType: 'PICKUP' })
expect(wrapper.get('.panel-header').text()).toContain('2026-10-15 · 提柜')
expect(wrapper.find('.panel-body .work-pagination').exists()).toBe(false)
expect(wrapper.get('.panel-footer').text()).toContain('共 30 项')
await wrapper.get('.panel-footer .work-pagination button:last-child').trigger('click'); await flushPromises()
expect(getWorkItems).toHaveBeenLastCalledWith(expect.objectContaining({ date: '2026-10-15', types: ['PICKUP'], page: 2 }), expect.any(AbortSignal))
await wrapper.get('[aria-label="关闭工作面板"]').trigger('click'); await flushPromises()
expect(wrapper.find('.work-panel').exists()).toBe(false)
expect(calendar.props('selectedDate')).toBeUndefined()
it('keeps available arrivals while reporting a failed warehouse source instead of inventing defaults', async () => {
vi.mocked(getWarehouseArrivals).mockRejectedValue(new Error('offline'))
const wrapper = renderSchedule(); await flushPromises()
expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events').map((event: ScheduleEvent) => event.type)).toEqual(['arrival'])
expect(wrapper.text()).toContain('计划到仓读取失败')
expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('incomplete')).toBe(true)
wrapper.unmount()
})
it('expands the existing detail without remounting or bypassing the unsaved close guard', async () => {
const wrapper = render(); await flushPromises()
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
await wrapper.get('.work-row-action button').trigger('click'); await flushPromises()
const detail = wrapper.getComponent({ name: 'WorkDetailPanel' }).vm
routing.allowLeave = false
await wrapper.get('[aria-label="展开全屏"]').trigger('click')
expect(wrapper.get('.el-dialog').classes()).toContain('is-fullscreen')
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).vm).toBe(detail)
await wrapper.get('[aria-label="关闭工作面板"]').trigger('click'); await flushPromises()
expect(wrapper.find('.work-panel').exists()).toBe(true)
routing.allowLeave = true
await wrapper.get('[aria-label="关闭工作面板"]').trigger('click'); await flushPromises()
expect(wrapper.find('.work-panel').exists()).toBe(false)
it('does not request unreadable domains and describes the incomplete scope', async () => {
routing.denied.add('platform:fleet:trip:list'); routing.denied.add('platform:overseas-warehouse:last-mile-order:list')
const wrapper = renderSchedule(); await flushPromises()
expect(listTripCalendar).not.toHaveBeenCalled(); expect(getWarehouseArrivals).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('缺少车次读取权限')
expect(wrapper.text()).toContain('缺少尾程订单读取权限')
wrapper.unmount()
})
it.each(['mask', 'escape'] as const)('closes from %s through the real dialog without bypassing draft protection', async method => {
const wrapper = render(true); await flushPromises()
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
await wrapper.get('.work-row-action button').trigger('click'); await flushPromises()
await wrapper.get('.panel-body').trigger('click')
expect(wrapper.find('[data-detail]').exists()).toBe(true)
const dismiss = async () => {
if (method === 'mask') {
const mask = wrapper.get('.el-overlay-dialog')
await mask.trigger('mousedown'); await mask.trigger('mouseup'); await mask.trigger('click')
} else {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true }))
}
await flushPromises()
}
routing.allowLeave = false
await dismiss()
expect(wrapper.find('[data-detail]').exists()).toBe(true)
routing.allowLeave = true
await dismiss()
expect(wrapper.find('.work-panel').exists()).toBe(false)
it('ignores stale calendar responses when the date range changes', async () => {
let resolveOld!: (value: Awaited<ReturnType<typeof getCalendarSources>>) => void
vi.mocked(getCalendarSources).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
const wrapper = renderSchedule(); await flushPromises()
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('range', { from: '2026-11-01', to: '2026-11-08' }); await flushPromises()
resolveOld({ containers: [], emptyReturns: [], emptyReturnsReadable: false }); await flushPromises()
expect(wrapper.text()).not.toContain('缺少还柜读取权限')
expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events')).toHaveLength(0)
wrapper.unmount()
})
it.each(['VIEW_DETAIL', 'VIEW_HISTORY', 'SAVE_FOLLOWUP'] as const)('keeps %s navigation intact while distinguishing read-only actions', async code => {
const nextAction = { ...followAction, code, label: '服务端动作文案' }
vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem({ nextAction })], page: 1, size: 8, total: 1, pages: 1 })
const wrapper = render(); await flushPromises()
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
const button = wrapper.get('.work-row-action button')
expect(button.attributes('data-variant')).toBe(code === 'SAVE_FOLLOWUP' ? 'primary' : 'default')
expect(wrapper.get('.item-schedule').text()).toContain('作业日期未定')
expect(wrapper.get('.item-schedule').text()).toContain('America/Los_Angeles')
expect(wrapper.get('.item-context').text()).toContain('地点:ONT8')
expect(wrapper.get('.item-context').text()).toContain('负责人:测试调度')
expect(wrapper.get('.item-context').text()).toContain('跟进:2026-10-15')
await button.trigger('click'); await flushPromises()
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('initialAction')).toEqual(nextAction)
it('retries an unknown save with the same payload and key, then only reloads reads', async () => {
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('network interrupted')).mockResolvedValueOnce({})
const wrapper = renderSchedule(); await flushPromises()
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
await wrapper.get('input[type="datetime-local"]').setValue('2026-10-22T09:30')
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('提交结果尚未确认')
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
const guard = vi.mocked(onBeforeRouteLeave).mock.calls.at(-1)![0] as () => Promise<boolean>
expect(await guard()).toBe(false)
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(vi.mocked(saveWarehouseArrival).mock.calls[1]).toEqual(vi.mocked(saveWarehouseArrival).mock.calls[0])
expect(saveWarehouseArrival).toHaveBeenCalledWith('1', { estimatedWarehouseArrivalAt: '2026-10-22T09:30:00', expectedVersion: 0 }, expect.any(String))
expect(wrapper.find('[data-dialog]').exists()).toBe(false)
wrapper.unmount()
})
it('sends an explicit safe return context with external business navigation', async () => {
routing.query = { workAnchor: '2026-10-15', workItem: 'work:1', workScope: 'global', workView: 'UNDATED', workPage: '3', workOrigins: '["work:0"]' }
const wrapper = render(); await flushPromises()
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('external', '/overseas-master-data/trips/TR-001?tab=loading'); await flushPromises()
expect(routing.replace).toHaveBeenCalledWith({ query: expect.objectContaining({ workItem: 'work:1', workOrigins: '["work:0"]', workPage: '3' }) })
expect(routing.push).toHaveBeenCalledWith({ path: '/overseas-master-data/trips/TR-001', query: { tab: 'loading', appointmentReturn: expect.stringContaining('/appointment-calendar?') } })
it('requires an explicit reread after a conflict and retains the entered date', async () => {
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new ApiResponseError('版本已变化', 'A0409', 409))
const wrapper = renderSchedule(); await flushPromises()
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
await wrapper.get('input').setValue('2026-10-22T09:30')
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(wrapper.get('[data-save]').attributes('disabled')).toBeDefined()
vi.mocked(getWarehouseArrivals).mockResolvedValueOnce([{ ...warehouse, version: 2 }])
await wrapper.get('[data-retry]').trigger('click'); await flushPromises()
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30')
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(saveWarehouseArrival).toHaveBeenLastCalledWith('1', expect.objectContaining({ expectedVersion: 2 }), expect.any(String))
wrapper.unmount()
})
it('keeps a successful save in the current detail and retains the original global page on return', async () => {
routing.query = { workAnchor: '2026-10-15', workItem: 'work:1', workScope: 'global', workView: 'UNDATED', workPage: '3' }
const wrapper = render(); await flushPromises()
const detail = wrapper.getComponent({ name: 'WorkDetailPanel' }).vm
detail.$emit('saved', { item: workItem(), affectedWorkItemKeys: [] }, 'SAVE_FOLLOWUP'); await flushPromises()
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).vm).toBe(detail)
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 3, size: 8 }, expect.any(AbortSignal))
await wrapper.get('.panel-nav button').trigger('click'); await flushPromises()
expect(wrapper.find('[data-detail]').exists()).toBe(false)
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 3, size: 8 }, expect.any(AbortSignal))
it('keeps the selected date and view in the source return link', async () => {
const wrapper = renderSchedule(); await flushPromises()
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
panel.vm.$emit('select', '2026-10-21', 'week')
panel.vm.$emit('open', { ...row, sourcePath: '/container-loading/overseas/1' }); await flushPromises()
expect(routing.push).toHaveBeenCalledWith({ path: '/container-loading/overseas/1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } })
wrapper.unmount()
})
it('retains all global counts and unfiltered global detail through month, flow and all-off layer changes', async () => {
const wrapper = render(); await flushPromises()
await wrapper.get('[data-view="UNDATED"]').trigger('click'); await flushPromises()
const calendar = wrapper.getComponent({ name: 'WorkCalendarPanel' })
calendar.vm.$emit('anchor', 1); await flushPromises()
calendar.vm.$emit('flow', 'DIRECT'); await flushPromises()
const calls = vi.mocked(getWorkCalendar).mock.calls.length
calendar.vm.$emit('types', []); await flushPromises()
expect(getWorkCalendar).toHaveBeenCalledTimes(calls)
expect(getWorkSummary).toHaveBeenCalledTimes(1)
expect(wrapper.get('[data-view="UNDATED"]').text()).toContain('7')
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 1, size: 8 }, expect.any(AbortSignal))
expect(wrapper.text()).toContain('作业日期未定'); expect(wrapper.text()).toContain('跨月份、流程与类型')
wrapper.unmount()
})
it('uses explicit day and type filters only for date details, and includes completed work through the server bucket', async () => {
const wrapper = render(); await flushPromises()
wrapper.getComponent({ name: 'WorkCalendarPanel' }).vm.$emit('day', '2026-10-15', 'PICKUP'); await flushPromises()
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'ALL', page: 1, size: 8, date: '2026-10-15', types: ['PICKUP'], flow: undefined, bucket: undefined }, expect.any(AbortSignal))
await wrapper.findAll('.bucket-buttons button').find(button => button.text() === '已完成')!.trigger('click'); await flushPromises()
expect(vi.mocked(getWorkItems).mock.calls.at(-1)![0].bucket).toBe('COMPLETED')
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'ALL', page: 1, size: 8 }, expect.any(AbortSignal))
wrapper.unmount()
})
it('shows a failed source as an error, not zero work or all complete', async () => {
vi.mocked(getWorkSummary).mockRejectedValue(new Error('source unavailable'))
vi.mocked(getWorkCalendar).mockRejectedValue(new Error('source unavailable'))
vi.mocked(getWorkItems).mockRejectedValue(new Error('source unavailable'))
const wrapper = render(); await flushPromises()
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
expect(wrapper.get('[data-view="ALL"]').text()).toContain('加载失败')
expect(wrapper.getComponent({ name: 'WorkCalendarPanel' }).props('failed')).toBe(true)
expect(wrapper.text()).toContain('工作明细读取失败'); expect(wrapper.text()).not.toContain('当前范围没有工作事项')
wrapper.unmount()
})
it('ignores a late response after the global scope changes', async () => {
let resolveOld!: (value: Awaited<ReturnType<typeof getWorkItems>>) => void
vi.mocked(getWorkItems).mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve }))
const wrapper = render(); await flushPromises()
await wrapper.get('[data-view="OVERDUE"]').trigger('click')
await wrapper.get('[data-view="UNDATED"]').trigger('click'); await flushPromises()
resolveOld({ records: [workItem({ title: '过期响应不得覆盖' })], page: 1, size: 8, total: 1, pages: 1 }); await flushPromises()
expect(wrapper.text()).not.toContain('过期响应不得覆盖'); expect(wrapper.text()).toContain('批次 SD-001')
wrapper.unmount()
})
it('returns from a prerequisite to the original item and preserves the original undated list context', async () => {
const wrapper = render(); await flushPromises()
await wrapper.get('[data-view="UNDATED"]').trigger('click'); await flushPromises()
await wrapper.get('.work-row-action button').trigger('click'); await flushPromises()
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('initialAction')).toEqual(followAction)
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('open', 'work-pickup-1', followAction); await flushPromises()
expect(wrapper.text()).toContain('返回原事项')
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('saved'); await flushPromises()
expect(wrapper.getComponent({ name: 'WorkDetailPanel' }).props('itemKey')).toBe('work-delivery-1')
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 1, size: 8 }, expect.any(AbortSignal))
wrapper.unmount()
})
it('goes directly to the current container unfinished list after route confirmation', async () => {
const wrapper = render(); await flushPromises()
await wrapper.get('[data-view="ALL"]').trigger('click'); await flushPromises()
await wrapper.get('.work-row-action button').trigger('click'); await flushPromises()
wrapper.getComponent({ name: 'WorkDetailPanel' }).vm.$emit('saved', { item: workItem(), affectedWorkItemKeys: ['pickup', 'receipt'] }, 'CONFIRM_ROUTE'); await flushPromises()
expect(wrapper.findComponent({ name: 'WorkDetailPanel' }).exists()).toBe(false)
expect(wrapper.text()).toContain('TEST-CN-001 · 本柜待办')
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'ALL', page: 1, size: 8, containerBookingId: '9007199254740993' }, expect.any(AbortSignal))
expect(getWorkSummary).toHaveBeenCalledTimes(2)
wrapper.unmount()
})
it('opens explicit source verification without forging an appointment or guessing a customer', async () => {
routing.query = { ...routing.query, sourceKey: 'BOOKING:B-1', customerId: 'customer-a', destinationKey: 'FBA:ONT8' }
const wrapper = render(); await flushPromises()
expect(wrapper.getComponent({ name: 'SourceAppointmentPanel' }).props('source')).toEqual({ sourceKey: 'BOOKING:B-1', customerId: 'customer-a', destinationKey: 'FBA:ONT8' })
wrapper.unmount()
routing.canReadSource = false
const denied = render(); await flushPromises()
expect(denied.findComponent({ name: 'SourceAppointmentPanel' }).exists()).toBe(false)
expect(denied.text()).toContain('没有货源读取权限'); denied.unmount()
})
})
@@ -1,303 +1,192 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onActivated, onDeactivated, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
import { computed, defineAsyncComponent, onActivated, onDeactivated, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { isAxiosError } from 'axios'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { addDays, calendarRange, dateKey, moveCalendar, type CalendarMode } from './calendar'
import { listArrivalCalendar, type ArrivalCalendarEntry } from './api'
import { getWorkCalendar, getWorkItems, getWorkSummary, workTypes, type WorkAction, type WorkCalendar, type WorkCommand, type WorkCommandResult, type WorkBucket, type WorkFlow, type WorkItemsQuery, type WorkPage, type WorkSummary, type WorkType, type WorkView } from './workApi'
import { flowLabels, typeLabels, viewLabels, workDate, workStatus } from './workPresentation'
import type { LeaveGuard } from './useWorkDraft'
import WorkCalendarPanel from './WorkCalendarPanel.vue'
import WorkDetailPanel from './WorkDetailPanel.vue'
import SourceAppointmentPanel from './SourceAppointmentPanel.vue'
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 { rejectedCommand, type LeaveGuard } from './useWorkDraft'
import type { PlanningSourceLookup } from './planningApi'
import type { WorkAction } from './workApi'
import { appointmentExternalTarget } from './workNavigation'
import { useWorkSyncRead } from './useWorkSyncRead'
// 原业务页面仍能直接进入其预约表单;日历不再提供工作清单或流程编排入口。
const WorkDetailPanel = defineAsyncComponent(() => import('./WorkDetailPanel.vue'))
const SourceAppointmentPanel = defineAsyncComponent(() => import('./SourceAppointmentPanel.vue'))
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const stringQuery = (key: string) => typeof route.query[key] === 'string' ? route.query[key] as string : ''
const savedAnchor = stringQuery('workAnchor')
function initialAnchor() { try { calendarRange(savedAnchor, 'month'); return savedAnchor } catch { return dateKey(new Date()) } }
const anchor = ref(initialAnchor())
const mode = ref<CalendarMode>(stringQuery('workMode') === 'week' ? 'week' : 'month')
const flow = ref<WorkFlow | ''>(['TRANSFER', 'DIRECT', 'UNKNOWN'].includes(stringQuery('workFlow')) ? stringQuery('workFlow') as WorkFlow : '')
const types = ref<WorkType[]>(route.query.workTypes !== undefined ? workTypes.filter(type => stringQuery('workTypes').split(',').includes(type)) : [...workTypes])
const reference = ref(false), arrivals = ref<ArrivalCalendarEntry[]>([]), referenceReady = ref(false)
const calendar = ref<WorkCalendar | null>(null), summary = ref<WorkSummary | null>(null), items = ref<WorkPage | null>(null)
const calendarLoading = ref(false), summaryLoading = ref(false), itemsLoading = ref(false), referenceLoading = ref(false)
const calendarError = ref<ApiErrorDetails | null>(null), summaryError = ref<ApiErrorDetails | null>(null), itemsError = ref<ApiErrorDetails | null>(null), referenceError = ref<ApiErrorDetails | null>(null)
type Scope = { kind: 'global'; view: WorkView } | { kind: 'day'; date: string; types: WorkType[]; flow: WorkFlow | ''; bucket?: WorkBucket } | { kind: 'container'; containerBookingId: string; label: string } | { kind: 'search'; keyword: string } | { kind: 'arrivals'; date: string }
const scope = ref<Scope | null>(null), page = ref(1), detailKey = ref<string | null>(null), initialAction = ref<WorkAction | null>(null)
const origins = ref<string[]>([]), search = ref(''), sourceLink = ref<PlanningSourceLookup | null>(null), linkError = ref('')
const sourceLinkKey = computed(() => sourceLink.value ? `${sourceLink.value.customerId}:${'sourceKey' in sourceLink.value ? sourceLink.value.sourceKey : sourceLink.value.orderId}:${sourceLink.value.destinationKey || ''}` : '')
const navigationError = ref('')
const summaryButtons: Array<{ view: WorkView; field: keyof WorkSummary }> = [{ view: 'ALL', field: 'all' }, { view: 'FOLLOW_TODAY', field: 'followToday' }, { view: 'OVERDUE', field: 'overdue' }, { view: 'UNDATED', field: 'undated' }, { view: 'REVIEW', field: 'review' }]
const buckets: Array<{ key?: WorkBucket; label: string }> = [{ label: '全部' }, { key: 'PENDING', label: '待安排' }, { key: 'CONFIRMED', label: '已落实' }, { key: 'NO_BOOKING_REQUIRED', label: '无需预约' }, { key: 'REVIEW', label: '需复核' }, { key: 'COMPLETED', label: '已完成' }]
const range = computed(() => calendarRange(anchor.value, mode.value))
const heading = computed(() => {
const value = scope.value
if (!value) return ''
if (value.kind === 'global') return viewLabels[value.view]
if (value.kind === 'day') return `${value.date} · ${value.types.length === 1 ? typeLabels[value.types[0]!] : '当天工作'}`
if (value.kind === 'container') return `${value.label} · 本柜待办`
if (value.kind === 'arrivals') return `${value.date} · 到港参考`
return `查找:${value.keyword}`
})
const scopeDescription = computed(() => scope.value?.kind === 'day' ? '按所选日期、流程与类型查询,包含已完成事项' : scope.value?.kind === 'arrivals' ? '独立到港来源,不计工作量;来源未提供流程筛选,展示该日全部参考' : '跨月份、流程与类型,包含未预约和未定日期事项')
const panelOpen = computed(() => !!scope.value || !!detailKey.value || !!sourceLink.value)
// Cached routes must not leave their body-teleported dialog over another workspace.
const pageActive = ref(true)
onActivated(() => { const returning = !pageActive.value; pageActive.value = true; if (returning) void refreshWork() })
onDeactivated(() => {
pageActive.value = false
++calendarRequest; ++summaryRequest; ++itemsRequest
calendarController?.abort(); summaryController?.abort(); itemsController?.abort()
})
const panelBody = ref<HTMLElement | null>(null)
const selectedDate = computed(() => scope.value?.kind === 'day' || scope.value?.kind === 'arrivals' ? scope.value.date : undefined)
const selectedType = computed(() => scope.value?.kind === 'day' && scope.value.types.length === 1 ? scope.value.types[0] : undefined)
const panelTitle = computed(() => sourceLink.value ? '货源与预约' : detailKey.value ? '事项详情' : heading.value)
const listVisible = computed(() => !sourceLink.value && !detailKey.value && scope.value?.kind !== 'arrivals')
watch([detailKey, sourceLinkKey, scope, page], () => { if (panelBody.value) panelBody.value.scrollTop = 0 }, { flush: 'post' })
function initialDate() {
const value = typeof route.query.date === 'string' ? route.query.date : ''
try { calendarRange(value, 'month'); return value } catch { return dateKey(new Date()) }
}
const selectedDate = ref(initialDate()), mode = ref<CalendarMode>(route.query.mode === 'week' ? 'week' : 'month')
const range = ref<ScheduleRange>(calendarRange(selectedDate.value, mode.value))
const sources = ref<CalendarSources | null>(null), warehouses = ref<WarehouseArrival[] | null>(null), trips = ref<ScheduleEvent[]>([])
const errors = ref<Array<{ title: string; details: ApiErrorDetails }>>([]), notices = ref<string[]>([]), loading = ref(false), active = ref(true)
const incomplete = computed(() => !!errors.value.length || !!notices.value.length)
const events = computed(() => [...projectContainerSchedule(sources.value || { containers: [], emptyReturns: [], emptyReturnsReadable: true },
warehouses.value, range.value, access.can('platform:firstleg:terminal-drayage:list')), ...trips.value])
let generation = 0, controller: AbortController | null = null
async function load(next = range.value) {
range.value = next
const request = ++generation; controller?.abort(); controller = new AbortController()
const signal = controller.signal
loading.value = true; sources.value = null; warehouses.value = null; trips.value = []; errors.value = []; notices.value = []
const fail = (title: string, cause: unknown) => { if (request === generation) errors.value.push({ title, details: getApiErrorDetails(cause, title) }) }
await Promise.allSettled([
(async () => {
let result: CalendarSources | null = null
try {
result = await getCalendarSources(next, signal)
if (request !== generation) return
sources.value = result
if (!result.emptyReturnsReadable) notices.value.push('缺少还柜读取权限,当前未显示还柜安排。')
} catch (cause) { fail('到港与还柜读取失败', cause) }
if (request !== generation) return
if (!access.can('platform:overseas-warehouse:last-mile-order:list')) {
notices.value.push('缺少尾程订单读取权限,当前未显示计划到仓。'); return
}
try {
const rows = await getWarehouseArrivals(next, [...new Set(result?.containers.map(row => row.containerBookingId) || [])], signal)
if (request === generation) warehouses.value = rows
} catch (cause) { fail('计划到仓读取失败', cause) }
})(),
(async () => {
if (!access.can('platform:fleet:trip:list')) { notices.value.push('缺少车次读取权限,当前未显示排车安排。'); return }
try {
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} 个车次缺少起运仓或时区资料,请到车辆管理核对。`)
} catch (cause) { fail('排车读取失败', cause) }
})(),
])
if (request === generation) loading.value = false
}
function select(date: string, nextMode: CalendarMode) { selectedDate.value = date; mode.value = nextMode }
async function openSource(event: ScheduleEvent) {
if (!event.sourcePath || !await canLeave()) return
const context = { date: selectedDate.value, mode: mode.value }
await router.replace({ query: context })
await router.push(appointmentExternalTarget(event.sourcePath, context))
}
const editor = ref<ScheduleEvent | null>(null), plannedAt = ref(''), originalAt = ref(''), saving = ref(false)
const saveError = ref<ApiErrorDetails | null>(null), unknownResult = ref(false), conflict = ref(false)
let pending: { id: string; body: WarehouseArrivalChange; key: string } | null = null
function edit(event: ScheduleEvent) {
if (!event.canEdit || !event.warehouse) return
editor.value = event; plannedAt.value = event.warehouse.plannedAt?.slice(0, 16) || ''; originalAt.value = plannedAt.value
pending = null; saveError.value = null; unknownResult.value = false; conflict.value = false
}
async function save() {
const row = editor.value?.warehouse
if (!row || saving.value || conflict.value || !plannedAt.value) return
pending ||= { id: row.sourceContainerBookingId, body: { estimatedWarehouseArrivalAt: `${plannedAt.value}:00`, expectedVersion: row.version },
key: `calendar-arrival-${crypto.randomUUID()}` }
saving.value = true; saveError.value = null
try {
await saveWarehouseArrival(pending.id, pending.body, pending.key)
pending = null; unknownResult.value = false; editor.value = null
ElMessage.success('计划到仓已保存')
await load()
} catch (cause) {
saveError.value = getApiErrorDetails(cause, '计划到仓保存失败')
unknownResult.value = !rejectedCommand(cause)
const status = cause instanceof ApiResponseError ? cause.status : isAxiosError(cause) ? cause.response?.status : undefined
conflict.value = status === 409 || /409$/.test(saveError.value.code || '')
if (!unknownResult.value) pending = null
} finally { saving.value = false }
}
async function rereadEditor() {
const row = editor.value?.warehouse
if (!row) return
saving.value = true
try {
const rows = await getWarehouseArrivals(range.value, [row.sourceContainerBookingId])
const latest = rows.find(value => value.sourceContainerBookingId === row.sourceContainerBookingId)
if (!latest?.canEdit) { saveError.value = { message: '该柜已不能调整计划到仓,请关闭并查看最新货柜记录。' }; return }
editor.value = { ...editor.value!, warehouse: latest }; conflict.value = false; saveError.value = null
} catch (cause) { saveError.value = getApiErrorDetails(cause, '最新计划读取失败') }
finally { saving.value = false }
}
const guards = new Set<LeaveGuard>()
provide('appointment-work-leave-guard', (guard: LeaveGuard) => { guards.add(guard); return () => { guards.delete(guard) } })
const canLeave: LeaveGuard = async () => { for (const guard of guards) if (!await guard()) return false; return true }
provide('appointment-work-leave-guard', (guard: LeaveGuard) => { guards.add(guard); return () => guards.delete(guard) })
async function canLeave() {
if (saving.value || unknownResult.value) return false
for (const guard of guards) if (!await guard()) return false
if (editor.value && plannedAt.value !== originalAt.value) {
try { await ElMessageBox.confirm('计划到仓尚未保存,确定放弃本次修改?', '未保存修改', { type: 'warning', customClass: 'admin-density-confirm' }) }
catch { return false }
}
return true
}
async function closeEditor(done: () => void) { if (await canLeave()) { done(); editor.value = null } }
onBeforeRouteLeave(canLeave)
onBeforeRouteUpdate((to, from) => to.query.sourceKey !== from.query.sourceKey || to.query.orderId !== from.query.orderId || to.query.customerId !== from.query.customerId || to.query.destinationKey !== from.query.destinationKey || to.query.workItem !== from.query.workItem ? canLeave() : true)
let calendarRequest = 0, summaryRequest = 0, itemsRequest = 0, referenceRequest = 0
let calendarController: AbortController | null = null, summaryController: AbortController | null = null, itemsController: AbortController | null = null
const calendarRead = useWorkSyncRead(), summaryRead = useWorkSyncRead(), itemsRead = useWorkSyncRead()
const syncing = computed(() => calendarRead.syncing.value || summaryRead.syncing.value || itemsRead.syncing.value)
async function loadCalendar() {
const request = ++calendarRequest; calendarController?.abort(); calendarController = new AbortController(); calendarError.value = null; calendar.value = null
if (!types.value.length) { calendarLoading.value = false; return }
calendarLoading.value = true
try {
const query = { from: range.value.from, to: addDays(range.value.to, -1), flow: flow.value || undefined, types: [...types.value] }
const signal = calendarController.signal
const result = await calendarRead.read(() => getWorkCalendar(query, signal), signal)
if (request === calendarRequest) calendar.value = result
} catch (cause) { if (request === calendarRequest) calendarError.value = getApiErrorDetails(cause, '工作日历读取失败') }
finally { if (request === calendarRequest) calendarLoading.value = false }
onBeforeRouteUpdate(canLeave)
// 仅承接原业务仍在使用的精确来源/事项深链,不恢复旧全局待办、分页和筛选协议。
const workKey = ref(''), origins = ref<string[]>([]), sourceLink = ref<PlanningSourceLookup | null>(null), initialAction = ref<WorkAction | null>(null)
watch(() => [route.query.workItem, route.query.sourceKey, route.query.orderId, route.query.customerId, route.query.destinationKey], () => {
workKey.value = typeof route.query.workItem === 'string' ? route.query.workItem : ''
const customerId = typeof route.query.customerId === 'string' ? route.query.customerId : ''
const destinationKey = typeof route.query.destinationKey === 'string' ? route.query.destinationKey : undefined
sourceLink.value = customerId && typeof route.query.sourceKey === 'string' ? { customerId, sourceKey: route.query.sourceKey, destinationKey }
: customerId && typeof route.query.orderId === 'string' ? { customerId, orderId: route.query.orderId, destinationKey } : null
origins.value = []
initialAction.value = null
}, { immediate: true })
async function openWork(key: string, action?: WorkAction) {
if (await canLeave()) { if (workKey.value) origins.value.push(workKey.value); workKey.value = key; initialAction.value = action || null }
}
async function loadSummary() {
const request = ++summaryRequest; summaryController?.abort(); summaryController = new AbortController(); summaryLoading.value = true; summaryError.value = null
const signal = summaryController.signal
try { const result = await summaryRead.read(() => getWorkSummary(signal), signal); if (request === summaryRequest) summary.value = result }
catch (cause) { if (request === summaryRequest) { summaryError.value = getApiErrorDetails(cause, '全局待办读取失败'); summary.value = null } }
finally { if (request === summaryRequest) summaryLoading.value = false }
}
function itemQuery(): WorkItemsQuery | null {
const value = scope.value
if (!value || value.kind === 'arrivals') return null
const query: WorkItemsQuery = { view: value.kind === 'global' ? value.view : 'ALL', page: page.value, size: 8 }
if (value.kind === 'day') Object.assign(query, { date: value.date, types: value.types, flow: value.flow || undefined, bucket: value.bucket })
if (value.kind === 'container') query.containerBookingId = value.containerBookingId
if (value.kind === 'search') query.keyword = value.keyword
return query
}
async function loadItems() {
const request = ++itemsRequest; itemsController?.abort(); itemsController = new AbortController(); itemsError.value = null; items.value = null
const query = itemQuery(); if (!query) { itemsLoading.value = false; return }
itemsLoading.value = true
const signal = itemsController.signal
try { const result = await itemsRead.read(() => getWorkItems(query, signal), signal); if (request === itemsRequest) items.value = result }
catch (cause) { if (request === itemsRequest) itemsError.value = getApiErrorDetails(cause, '工作明细读取失败') }
finally { if (request === itemsRequest) itemsLoading.value = false }
}
async function loadArrivals() {
const request = ++referenceRequest; referenceError.value = null; referenceReady.value = false; arrivals.value = []
if (!reference.value) { referenceLoading.value = false; return }
if (!access.can('platform:firstleg:port-schedule:list')) { referenceError.value = { message: '当前账号没有到港参考读取权限,工作事项仍可正常查看。' }; return }
referenceLoading.value = true
try { const result = await listArrivalCalendar({ ...range.value }); if (request === referenceRequest) { arrivals.value = result; referenceReady.value = true } }
catch (cause) { if (request === referenceRequest) referenceError.value = getApiErrorDetails(cause, '到港参考读取失败') }
finally { if (request === referenceRequest) referenceLoading.value = false }
}
async function setScope(value: Scope) {
async function backWork() { if (await canLeave()) { workKey.value = origins.value.pop() || ''; initialAction.value = null } }
async function closeBusiness(done: () => void) {
if (!await canLeave()) return
scope.value = value; detailKey.value = null; initialAction.value = null; origins.value = []; sourceLink.value = null; page.value = 1; await loadItems()
done(); workKey.value = ''; sourceLink.value = null; origins.value = []
await router.replace({ query: { date: selectedDate.value, mode: mode.value } })
}
async function openItem(key: string, action?: WorkAction) {
if (!await canLeave()) return
navigationError.value = ''
if (action && origins.value.includes(key)) { navigationError.value = '前置事项关系需要核对,请返回原事项并刷新后重试。'; return }
if (detailKey.value && detailKey.value !== key) origins.value.push(detailKey.value)
detailKey.value = key; initialAction.value = action || null; sourceLink.value = null
}
async function back() {
if (!await canLeave()) return
const origin = origins.value.pop()
detailKey.value = origin || null; initialAction.value = null; sourceLink.value = null
if (!origin && !scope.value) scope.value = { kind: 'global', view: 'ALL' }
if (!origin) await loadItems()
}
let closingPanel = false
async function closePanel(done: () => void) {
if (closingPanel) return
closingPanel = true
try {
if (!await canLeave()) return
done()
scope.value = null; detailKey.value = null; initialAction.value = null; sourceLink.value = null; origins.value = []; ++itemsRequest; itemsController?.abort()
} finally { closingPanel = false }
}
async function filterCalendar(change: () => void) {
if (!await canLeave()) return
change()
if (scope.value?.kind === 'day' || scope.value?.kind === 'arrivals') { scope.value = null; detailKey.value = null; initialAction.value = null; origins.value = [] }
await Promise.all([loadCalendar(), loadArrivals()])
}
async function toggleReference(value: boolean) { reference.value = value; await loadArrivals() }
async function searchItems() { if (search.value.trim()) await setScope({ kind: 'search', keyword: search.value.trim() }) }
async function changePage(next: number) { page.value = next; await loadItems() }
async function setBucket(bucket?: WorkBucket) { if (scope.value?.kind === 'day') { scope.value = { ...scope.value, bucket }; page.value = 1; await loadItems() } }
async function saved(result?: WorkCommandResult, command?: WorkCommand['command']) {
if (command === 'CONFIRM_ROUTE' && result?.item.containers[0]) {
const container = result.item.containers[0]
scope.value = { kind: 'container', containerBookingId: container.containerBookingId, label: container.containerNo || container.bookingNo || '本柜' }; page.value = 1; detailKey.value = null; origins.value = []; initialAction.value = null
} else { if (origins.value.length) detailKey.value = origins.value.pop()!; initialAction.value = null; sourceLink.value = null }
await Promise.all([loadCalendar(), loadSummary(), loadItems()])
}
async function refreshWork() { await Promise.all([loadCalendar(), loadSummary(), loadItems()]) }
async function external(path: string) {
if (!await canLeave()) return
const value = scope.value
const context = { workAnchor: anchor.value, workMode: mode.value, workFlow: flow.value || undefined, workTypes: types.value.join(','), workItem: detailKey.value || undefined, workOrigins: origins.value.length ? JSON.stringify(origins.value) : undefined, workScope: value?.kind, workView: value?.kind === 'global' ? value.view : undefined, workDate: value?.kind === 'day' ? value.date : undefined, workDayTypes: value?.kind === 'day' ? value.types.join(',') : undefined, workBucket: value?.kind === 'day' ? value.bucket : undefined, workContainer: value?.kind === 'container' ? value.containerBookingId : undefined, workContainerLabel: value?.kind === 'container' ? value.label : undefined, workKeyword: value?.kind === 'search' ? value.keyword : undefined, workPage: String(page.value) }
await router.replace({ query: context })
await router.push(appointmentExternalTarget(path, context))
await router.push(appointmentExternalTarget(path, { date: selectedDate.value, mode: mode.value, workItem: workKey.value || undefined }))
}
onMounted(() => {
const scopeKind = stringQuery('workScope'), restoredView = stringQuery('workView')
if (scopeKind === 'container' && stringQuery('workContainer')) scope.value = { kind: 'container', containerBookingId: stringQuery('workContainer'), label: stringQuery('workContainerLabel') || '本柜' }
else if (scopeKind === 'day' && /^\d{4}-\d{2}-\d{2}$/.test(stringQuery('workDate'))) scope.value = { kind: 'day', date: stringQuery('workDate'), flow: flow.value, types: workTypes.filter(type => stringQuery('workDayTypes').split(',').includes(type)), bucket: buckets.find(bucket => bucket.key === stringQuery('workBucket'))?.key }
else if (scopeKind === 'search') scope.value = { kind: 'search', keyword: stringQuery('workKeyword') }
else if (scopeKind === 'global') scope.value = { kind: 'global', view: summaryButtons.some(button => button.view === restoredView) ? restoredView as WorkView : 'ALL' }
const restoredPage = Number(stringQuery('workPage')); page.value = Number.isInteger(restoredPage) && restoredPage > 0 ? restoredPage : 1
detailKey.value = stringQuery('workItem') || null
try { const savedOrigins: unknown = JSON.parse(stringQuery('workOrigins') || '[]'); if (Array.isArray(savedOrigins) && savedOrigins.every(value => typeof value === 'string' && value.length < 256)) origins.value = savedOrigins.slice(-10) } catch { origins.value = [] }
void loadCalendar(); void loadSummary(); if (scope.value) void loadItems()
})
watch(() => [route.query.sourceKey, route.query.orderId, route.query.customerId, route.query.destinationKey], () => {
sourceLink.value = null; linkError.value = ''
const sourceKey = stringQuery('sourceKey'), orderId = stringQuery('orderId'), customerId = stringQuery('customerId'), destinationKey = stringQuery('destinationKey')
if (sourceKey || orderId || customerId || destinationKey) {
if (!access.can('platform:overseas-warehouse:last-mile-order:list')) linkError.value = '当前账号没有货源读取权限,请联系预约负责人处理。'
else if (sourceKey && orderId) linkError.value = '货物链接同时包含两种来源,请从原业务重新打开。'
else if (sourceKey && customerId) sourceLink.value = { sourceKey, customerId, destinationKey: destinationKey || undefined }
else if (orderId && customerId) sourceLink.value = { orderId, customerId, destinationKey: destinationKey || undefined }
else linkError.value = '货物链接缺少客户或原来源,请从原业务重新打开。'
}
}, { immediate: true })
onBeforeUnmount(() => { ++calendarRequest; ++summaryRequest; ++itemsRequest; ++referenceRequest; calendarController?.abort(); summaryController?.abort(); itemsController?.abort() })
onMounted(() => { void load() })
onActivated(() => { const returning = !active.value; active.value = true; if (returning) void load() })
onDeactivated(() => { active.value = false; ++generation; controller?.abort() })
onBeforeUnmount(() => { ++generation; controller?.abort() })
</script>
<template>
<AdminPage class="admin-density appointment-work" spacing="compact">
<div class="work-toolbar">
<header class="work-heading"><h2 class="density-detail-title">预约工作日历</h2><span>已开船事项 · 中转与直送统一查看</span></header>
<form class="work-search" role="search" @submit.prevent="searchItems"><label class="sr-only" for="appointment-work-search">查找工作事项</label><input id="appointment-work-search" v-model="search" type="search" maxlength="80" placeholder="柜号、送仓批次、车次、目的仓"><button type="submit">查找事项</button><span>跨日期查找未完成事项 · 含未定日期</span></form>
</div>
<p v-if="linkError" role="alert">{{ linkError }}</p>
<p v-if="syncing" role="status">预约工作清单同步中,正在自动读取最新日历与待办;已保存内容无需重复提交。</p>
<LoadErrorState v-if="calendarError" title="工作日历读取失败" :message="calendarError.message" :loading="calendarLoading" @retry="loadCalendar"><template #details><ApiErrorMeta :details="calendarError" /></template></LoadErrorState>
<LoadErrorState v-if="referenceError" title="到港参考不可用" :message="referenceError.message" :loading="referenceLoading" @retry="loadArrivals"><template #details><ApiErrorMeta :details="referenceError" /></template></LoadErrorState>
<div class="work-layout">
<WorkCalendarPanel :selected-date="selectedDate" :selected-type="selectedType" :anchor="anchor" :mode="mode" :flow="flow" :types="types" :calendar="calendar" :loading="calendarLoading" :failed="!!calendarError" :reference="reference" :arrivals="arrivals" :reference-ready="referenceReady" @anchor="offset => filterCalendar(() => anchor = moveCalendar(anchor, mode, offset))" @today="filterCalendar(() => anchor = dateKey(new Date()))" @mode="value => filterCalendar(() => mode = value)" @flow="value => filterCalendar(() => flow = value)" @types="value => filterCalendar(() => types = value)" @reference="toggleReference" @day="(date, type) => setScope({ kind: 'day', date, types: type ? [type] : [...types], flow })" @arrivals="date => setScope({ kind: 'arrivals', date })">
<template #inbox>
<nav class="work-inbox" aria-label="全局待办">
<button v-for="button in summaryButtons" :key="button.view" type="button" :data-view="button.view" :aria-pressed="scope?.kind === 'global' && scope.view === button.view" @click="setScope({ kind: 'global', view: button.view })">{{ viewLabels[button.view] }} <strong :class="{ 'is-zero': !summaryLoading && !summaryError && summary?.[button.field] === 0, 'is-overdue': !summaryLoading && !summaryError && button.view === 'OVERDUE' && (summary?.overdue ?? 0) > 0 }">{{ summaryLoading ? '…' : summaryError ? '加载失败' : summary?.[button.field] ?? '—' }}</strong></button><span>全局待办 · 不受日历筛选影响 · 数量可重叠</span>
</nav>
<LoadErrorState v-if="summaryError" title="全局待办读取失败" :message="summaryError.message" :loading="summaryLoading" @retry="loadSummary"><template #details><ApiErrorMeta :details="summaryError" /></template></LoadErrorState>
</template>
</WorkCalendarPanel>
<AppDialog v-if="pageActive" :model-value="panelOpen" :title="panelTitle" class="admin-density-dialog admin-density appointment-work work-dialog" width="min(1120px, calc(100vw - 48px))" fullscreenable append-to-body :close-on-click-modal="true" :close-on-press-escape="true" :before-close="closePanel" :show-footer="listVisible && !!items && !itemsError && !itemsLoading" destroy-on-close>
<template #header>
<header class="panel-header">
<div class="panel-nav"><button v-if="detailKey || sourceLink" type="button" @click="back">‹ {{ origins.length ? '返回原事项' : '返回工作明细' }}</button><h3>{{ panelTitle }}</h3></div>
<template v-if="!detailKey && !sourceLink">
<p class="scope-description">{{ scopeDescription }}</p>
<div v-if="scope?.kind === 'day'" class="bucket-buttons"><button v-for="bucket in buckets" :key="bucket.label" type="button" :aria-pressed="scope.bucket === bucket.key" @click="setBucket(bucket.key)">{{ bucket.label }}</button></div>
</template>
</header>
</template>
<section v-if="panelOpen" class="work-panel" aria-label="工作明细与事项操作">
<div ref="panelBody" class="panel-body">
<p v-if="origins.length" class="origin-context">正在处理关联前置事项,完成后可继续原事项。</p>
<p v-if="navigationError" role="alert">{{ navigationError }}</p>
<SourceAppointmentPanel v-if="sourceLink" :key="sourceLinkKey" :source="sourceLink" :can-leave="canLeave" @updated="refreshWork" />
<WorkDetailPanel v-else-if="detailKey" :key="detailKey" :item-key="detailKey" :initial-action="initialAction" :can-leave="canLeave" @open="openItem" @external="external" @saved="saved" @updated="refreshWork" @container="(id, label) => setScope({ kind: 'container', containerBookingId: id, label })" />
<template v-else-if="scope?.kind === 'arrivals'"><p v-for="arrival in arrivals.filter(row => row.arrivalAt.slice(0, 10) === (scope?.kind === 'arrivals' ? scope.date : ''))" :key="arrival.containerBookingId">{{ arrival.containerNo || arrival.cabinetNo || '—' }} · {{ arrival.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港' }} {{ arrival.arrivalAt.replace('T', ' ') }}</p><p>到港时间沿用原船节点事实,不用于生成工作安排或实际执行记录。</p></template>
<template v-else>
<LoadErrorState v-if="itemsError" title="工作明细读取失败" :message="itemsError.message" :loading="itemsLoading" @retry="loadItems"><template #details><ApiErrorMeta :details="itemsError" /></template></LoadErrorState>
<p v-if="itemsLoading" role="status">正在读取明细…</p>
<template v-else-if="items && !itemsError">
<article v-for="item in items.records" :key="item.workItemKey" class="work-row">
<div class="work-row-action"><el-button v-if="item.nextAction" :type="['VIEW_DETAIL', 'VIEW_HISTORY'].includes(item.nextAction.code) ? 'default' : 'primary'" @click="openItem(item.workItemKey, item.nextAction)">{{ item.nextAction.label }}</el-button><el-button v-else @click="openItem(item.workItemKey)">查看详情</el-button></div>
<div class="work-row-content">
<div class="item-heading"><button type="button" class="item-title" @click="openItem(item.workItemKey)">{{ item.title }}</button><span class="item-status">{{ workStatus(item) }}</span></div>
<p class="item-schedule"><span class="item-date">{{ workDate(item) }}</span><span>{{ item.timezone || '地点时区待核实' }}</span></p>
<p class="item-context"><span v-if="!item.title.endsWith(typeLabels[item.type])">{{ typeLabels[item.type] }}</span><span>{{ flowLabels[item.flow] }}</span><span>地点:{{ item.destinationName || item.originName || '地点待核实' }}</span><span>负责人:{{ item.owner?.name || '待分派' }}</span><span v-if="item.followupDate">跟进:{{ item.followupDate }}</span></p>
<p v-for="reason in [...item.overdueReasons, ...item.blockReasons]" :key="reason" class="work-warning">{{ reason }}</p>
</div>
</article>
<p v-if="!items.records.length">当前范围没有工作事项。</p>
</template>
</template>
</div>
</section>
<template #footer><footer v-if="items" class="panel-footer"><span>共 {{ items.total }} 项</span><div v-if="items.total > 8" class="work-pagination"><button type="button" :disabled="page <= 1" @click="changePage(page - 1)">上一页</button><span>{{ page }} / {{ Math.max(1, Math.ceil(items.total / 8)) }}</span><button type="button" :disabled="page * 8 >= items.total" @click="changePage(page + 1)">下一页</button></div></footer></template>
</AppDialog>
</div>
<AdminPage class="admin-density appointment-schedule" spacing="compact">
<LoadErrorState v-for="error in errors" :key="error.title" :title="error.title" :message="error.details.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error.details" /></template></LoadErrorState>
<p v-for="notice in notices" :key="notice" role="status" class="data-notice">{{ notice }}</p>
<AppointmentCalendarPanel :events="events" :loading="loading" :incomplete="incomplete" :initial-date="selectedDate" :initial-mode="mode" @range="load" @select="select" @open="openSource" @edit="edit" />
<AppDialog v-if="active" :model-value="!!editor" title="调整计划到仓" class="admin-density-dialog" width="520px" :confirm-loading="saving" :confirm-disabled="!plannedAt || conflict" :before-close="closeEditor" :confirm-text="unknownResult ? '重试确认保存结果' : '保存'" @confirm="save">
<template v-if="editor">
<p>{{ editor.title }}</p>
<p class="data-notice">自动计划为到港日期加2个自然日;人工保存后优先使用人工时间。填写接仓地点当地时间。</p>
<label class="arrival-field">计划到仓时间<input v-model="plannedAt" type="datetime-local" :disabled="saving || unknownResult" required></label>
<p v-if="unknownResult" role="status">上次提交结果尚未确认,请使用原请求重试,避免重复修改。</p>
<LoadErrorState v-if="saveError" title="计划到仓未保存" :message="saveError.message" :loading="saving" @retry="conflict ? rereadEditor() : save()"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
<p v-if="conflict" role="status">重试将重新读取当前资格和版本并保留输入,核对后再次点击保存。</p>
</template>
</AppDialog>
<AppDialog v-if="active && (workKey || sourceLink)" :model-value="true" title="原业务预约办理" class="admin-density-dialog admin-density business-dialog" width="min(1120px, calc(100vw - 48px))" fullscreenable append-to-body :show-footer="false" :before-close="closeBusiness" :close-on-click-modal="true">
<el-button v-if="origins.length" link @click="backWork">返回原事项</el-button>
<WorkDetailPanel v-if="workKey" :key="workKey" :item-key="workKey" :initial-action="initialAction" :can-leave="canLeave" @open="openWork" @external="external" @saved="load()" @updated="load()" @container="id => external(`/container-loading/overseas/${encodeURIComponent(id)}`)" />
<SourceAppointmentPanel v-else-if="sourceLink" :key="JSON.stringify(sourceLink)" :source="sourceLink" :can-leave="canLeave" @updated="load()" />
</AppDialog>
</AdminPage>
</template>
<style scoped>
.work-toolbar, .work-heading, .work-inbox, .work-search { display: flex; align-items: center; gap: var(--admin-space-3); flex-wrap: wrap; }
.work-toolbar { justify-content: flex-start; column-gap: var(--admin-space-6); }
.work-heading h2 { margin: 0; }
.work-heading span, .work-inbox > span, .work-search > span, .scope-description { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.work-inbox { padding: var(--admin-space-2) var(--admin-space-5); }
.work-inbox button { display: flex; gap: var(--admin-space-3); align-items: center; }
.work-inbox strong { font-variant-numeric: tabular-nums; }
.work-inbox strong.is-zero { color: var(--el-text-color-secondary); font-weight: 400; }
.work-inbox strong.is-overdue { color: var(--el-color-danger-dark-2); }
.appointment-work .work-inbox button[aria-pressed=true] { box-shadow: inset 0 -2px 0 var(--el-color-primary); }
.work-search input { width: 280px; }
.appointment-work button:not(.el-button), .appointment-work input { font: inherit; }
.appointment-work button:not(.el-button) { cursor: pointer; min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--admin-surface); padding: 0 var(--admin-space-3); }
.appointment-work button:not(.el-button)[aria-pressed=true] { color: var(--el-color-primary); border-color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
.appointment-work input { min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); padding: 0 var(--admin-space-3); }
.appointment-work button:not(.el-button):focus-visible, .appointment-work input:focus-visible, .panel-header h3:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 1px; }
.work-layout { position: relative; min-width: 0; }
:global(.work-dialog.el-dialog:not(.is-fullscreen)) { height: 88dvh; max-height: 88dvh; margin-top: 6dvh; }
:global(.work-dialog .el-dialog__body) { display: flex; flex: 1; overflow: hidden; }
:global(.work-dialog .app-dialog__body) { display: flex; flex: 1; min-width: 0; }
.work-panel { min-width: 0; min-height: 0; flex: 1; display: flex; flex-direction: column; overflow-wrap: anywhere; }
.panel-header { flex: none; min-width: 0; font-size: var(--admin-font-body); font-weight: 400; }
.panel-nav { display: flex; align-items: center; gap: var(--admin-space-3); }
.panel-header h3 { font-size: var(--admin-font-title); margin: var(--admin-space-2) 0; }
.scope-description { margin: var(--admin-space-1) 0; }
.panel-body { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 0 var(--admin-space-1); }
.panel-footer { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); flex-wrap: wrap; flex: none; font-size: var(--admin-font-caption); }
.origin-context { background: var(--el-fill-color-light); padding: var(--admin-space-3); }
.work-row { display: grid; grid-template-columns: 108px minmax(0, 1fr); gap: var(--admin-space-3); padding: var(--admin-space-4) 0; border-bottom: 1px solid var(--cc-border); }
.work-row-action .el-button { white-space: normal; width: 100%; min-height: var(--admin-control-height); height: auto; padding: var(--admin-space-2); line-height: 20px; }
.work-row-action :deep(.el-button > span) { min-width: 0; max-width: 100%; white-space: normal; overflow-wrap: anywhere; line-height: inherit; }
.work-row-content { min-width: 0; }
.item-heading { display: flex; align-items: baseline; justify-content: space-between; gap: var(--admin-space-3); }
.item-status { flex: none; color: var(--el-text-color-regular); background: var(--el-fill-color-light); border-radius: var(--admin-radius); padding: 0 var(--admin-space-2); font-size: var(--admin-font-caption); line-height: 24px; }
.work-row-content p { margin: var(--admin-space-1) 0; color: var(--el-text-color-regular); font-size: var(--admin-font-body); line-height: 1.6; }
.item-schedule, .item-context { display: flex; flex-wrap: wrap; column-gap: var(--admin-space-4); row-gap: 2px; }
.item-date { color: var(--el-text-color-regular); font-variant-numeric: tabular-nums; }
.appointment-work .work-row-content .item-title { border: 0; padding: 0; text-align: left; min-height: 24px; color: var(--el-color-primary); background: transparent; font-weight: 600; }
.work-row-content .work-warning { color: var(--el-color-warning-dark-2); border-left: 2px solid var(--el-color-warning-light-5); padding-left: var(--admin-space-2); }
.work-pagination, .bucket-buttons { display: flex; align-items: center; gap: var(--admin-space-2); flex-wrap: wrap; }
.bucket-buttons { margin-top: var(--admin-space-2); }
.bucket-buttons button { font-size: var(--admin-font-caption); }
.work-panel :deep(.work-form fieldset) { border: 0; margin: 0; padding: 0; min-width: 0; }.work-panel :deep(.work-form label:not(.el-form-item__label)) { display: flex; flex-direction: column; gap: var(--admin-space-2); margin-bottom: var(--admin-space-5); font-size: var(--admin-font-caption); }.work-panel :deep(.work-form input:not(.el-input__inner)), .work-panel :deep(.work-form textarea:not(.el-textarea__inner)), .work-panel :deep(.work-form select) { box-sizing: border-box; width: 100%; font: inherit; min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); padding: var(--admin-space-2) var(--admin-space-3); }.work-panel :deep(.work-form input[type=checkbox]) { width: auto; align-self: flex-start; }.work-panel :deep(.work-form textarea:not(.el-textarea__inner)) { min-height: 64px; resize: vertical; }.work-panel :deep(.appointment-fields), .work-panel :deep(.appointment-fields__extra-grid) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
.data-notice { margin: 0; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
.arrival-field { display: flex; flex-direction: column; gap: var(--admin-space-2); margin-block: var(--admin-space-5); }
.arrival-field input { box-sizing: border-box; min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); padding: var(--admin-space-2) var(--admin-space-3); font: inherit; }
:global(.business-dialog.el-dialog:not(.is-fullscreen)) { max-height: 88dvh; }
</style>
@@ -1,86 +1,31 @@
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import { defineComponent, h, inject, KeepAlive, onBeforeUnmount, ref, type Ref } from 'vue'
import { ElDialog, ElPopover } from 'element-plus'
import type { LeaveGuard } from './useWorkDraft'
import { defineComponent, h, KeepAlive, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppointmentCalendarView from './AppointmentCalendarView.vue'
import { getWorkCalendar, getWorkItems, getWorkSummary } from './workApi'
import { listArrivalCalendar } from './api'
import { workCalendar, workItem, workSummary, followAction } from './work.test-support'
const routing = vi.hoisted(() => ({ query: {} as Record<string, string>, push: vi.fn(), replace: vi.fn(), canReadSource: true, allowLeave: true }))
vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission !== 'platform:overseas-warehouse:last-mile-order:list' || routing.canReadSource }) }))
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkCalendar: vi.fn(), getWorkItems: vi.fn(), getWorkSummary: vi.fn() }))
vi.mock('./api', async original => ({ ...await original<typeof import('./api')>(), listArrivalCalendar: vi.fn() }))
function render(realDialog = false, active?: Ref<boolean>) {
const host = active ? defineComponent({ setup: () => () => h(KeepAlive, null, { default: () => active.value ? h(AppointmentCalendarView) : h('div', { 'data-other-page': '' }) }) }) : AppointmentCalendarView
return mount(host, { attachTo: document.body, global: { components: { ElDialog, ElPopover }, directives: { loading: {} }, stubs: {
teleport: !active,
ElTooltip: active ? false : { template: '<slot />' },
ElDialog: realDialog ? ElDialog : { name: 'ElDialog', props: ['modelValue', 'title', 'fullscreen', 'beforeClose', 'closeOnClickModal', 'closeOnPressEscape'], emits: ['update:modelValue'], template: '<div v-if="modelValue" class="el-dialog" :class="{ \'is-fullscreen\': fullscreen }"><button aria-label="关闭工作面板" @click="beforeClose(() => $emit(\'update:modelValue\', false))">关闭</button><slot name="header" /><slot /><slot name="footer" /></div>' },
AdminPage: { template: '<main><slot /></main>' },
QueryToolbar: { name: 'QueryToolbar', props: ['modelValue', 'fields', 'loading'], emits: ['update:modelValue', 'search'], template: '<div data-list-search />' },
DataTable: { name: 'DataTable', props: ['data', 'columns', 'total', 'page', 'pageSize'], emits: ['update:page'], template: `<div><table><thead><tr><th v-for="column in columns" :key="column.key">{{ column.label }}</th></tr></thead><tbody><tr v-for="row in data" :key="row.workItemKey"><td v-for="column in columns" :key="column.key"><slot v-if="column.slotName" :name="column.slotName" :row="row" /><template v-else>{{ row[column.key] }}</template></td></tr></tbody></table><footer class="data-table__pagination"><slot name="pagination-left" /><button aria-label="下一页" @click="$emit('update:page', page + 1)">下一页</button></footer></div>` },
ElButton: { props: ['type'], template: '<button type="button" :data-variant="type"><slot /></button>' },
WorkCalendarPanel: { name: 'WorkCalendarPanel', props: ['anchor', 'mode', 'flow', 'types', 'calendar', 'failed', 'reference', 'selectedDate', 'selectedType'], template: '<div data-calendar><slot name="inbox" /></div>' },
WorkDetailPanel: { name: 'WorkDetailPanel', props: ['itemKey', 'initialAction', 'canLeave'], setup() {
const register = inject<(guard: LeaveGuard) => () => void>('appointment-work-leave-guard')!
const unregister = register(async () => routing.allowLeave)
onBeforeUnmount(unregister)
}, template: '<div data-detail>{{ itemKey }}</div>' },
SourceAppointmentPanel: { name: 'SourceAppointmentPanel', props: ['source'], template: '<div data-cargo />' },
LoadErrorState: { props: ['title', 'message'], template: '<section role="alert">{{ title }} {{ message }}<slot name="details" /><button @click="$emit(\'retry\')">重试</button></section>' },
ApiErrorMeta: true,
} } })
}
describe('appointment repair regression', () => {
afterEach(() => vi.useRealTimers())
import { getCalendarSources, getWarehouseArrivals } from './scheduleApi'
import { listTripCalendar } from './tripCalendar'
vi.mock('vue-router', () => ({ useRoute: () => ({ query: {} }), useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArrivals: vi.fn(), saveWarehouseArrival: vi.fn() }))
vi.mock('./tripCalendar', async original => ({ ...await original<typeof import('./tripCalendar')>(), listTripCalendar: vi.fn() }))
describe('calendar cache lifecycle', () => {
beforeEach(() => {
vi.clearAllMocks(); routing.query = { workAnchor: '2026-10-15' }; routing.canReadSource = true; routing.allowLeave = true
routing.replace.mockResolvedValue(undefined); routing.push.mockResolvedValue(undefined)
vi.mocked(getWorkCalendar).mockResolvedValue(workCalendar)
vi.mocked(getWorkSummary).mockResolvedValue(workSummary)
vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem()], page: 1, size: 8, total: 1, pages: 1 })
vi.mocked(listArrivalCalendar).mockResolvedValue([])
vi.clearAllMocks()
vi.mocked(getCalendarSources).mockResolvedValue({ containers: [], emptyReturns: [], emptyReturnsReadable: true })
vi.mocked(getWarehouseArrivals).mockResolvedValue([])
vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] })
})
it('waits for synchronization before restoring calendar, summary and current work without showing a failed save', async () => {
vi.useFakeTimers()
routing.query = { workScope: 'global', workView: 'ALL' }
const pending = new ApiResponseError('预约批次或运输关联已变化,服务端正在协调完整工作清单', 'A0409', 409)
vi.mocked(getWorkCalendar).mockRejectedValueOnce(pending)
vi.mocked(getWorkSummary).mockRejectedValueOnce(pending)
vi.mocked(getWorkItems).mockRejectedValueOnce(pending)
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('同步中')
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
expect(wrapper.text()).not.toContain('加载失败')
await vi.advanceTimersByTimeAsync(2000); await flushPromises()
expect(getWorkCalendar).toHaveBeenCalledTimes(2)
expect(getWorkSummary).toHaveBeenCalledTimes(2)
expect(getWorkItems).toHaveBeenCalledTimes(2)
expect(wrapper.text()).not.toContain('同步中')
wrapper.unmount()
})
it('cancels synchronization polling when leaving the cached calendar', async () => {
vi.useFakeTimers()
vi.mocked(getWorkSummary).mockRejectedValue(new ApiResponseError('预约批次或运输关联已变化,服务端正在协调完整工作清单', 'A0409', 409))
const active = ref(true), wrapper = render(false, active); await flushPromises()
expect(getWorkSummary).toHaveBeenCalledTimes(1)
it('aborts reads when cached and reloads on return without creating background work polling', async () => {
const active = ref(true)
const host = defineComponent({ setup: () => () => h(KeepAlive, null, { default: () => active.value ? h(AppointmentCalendarView) : h('div') }) })
const wrapper = mount(host, { global: { stubs: { AdminPage: { template: '<main><slot /></main>' }, AppointmentCalendarPanel: { template: '<section />' }, AppDialog: true } } })
await flushPromises()
expect(getCalendarSources).toHaveBeenCalledTimes(1)
const signal = vi.mocked(getCalendarSources).mock.calls[0]![1]!
active.value = false; await flushPromises()
await vi.advanceTimersByTimeAsync(30000)
expect(getWorkSummary).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('removes the teleported dialog while a cached appointment page is inactive and restores its context', async () => {
routing.query = { workScope: 'global', workView: 'UNDATED', workPage: '3', workItem: 'work:1' }
const active = ref(true), wrapper = render(true, active); await flushPromises()
expect(document.body.querySelector('.work-dialog [data-detail]')?.textContent).toBe('work:1')
active.value = false; await flushPromises()
expect(document.body.querySelector('.work-dialog')).toBeNull()
expect(signal.aborted).toBe(true)
active.value = true; await flushPromises()
expect(document.body.querySelector('.work-dialog [data-detail]')?.textContent).toBe('work:1')
expect(getWorkItems).toHaveBeenLastCalledWith({ view: 'UNDATED', page: 3, size: 8 }, expect.any(AbortSignal))
expect(getCalendarSources).toHaveBeenCalledTimes(2)
wrapper.unmount()
})
})
@@ -1,5 +1,24 @@
# 预约工作日历逐操作矩阵
## 2026-10-19 四类日历恢复(当前结论)
依据:用户本轮确认四类与日期规则,以及截图 `C:/Users/BY-OY/AppData/Local/Temp/codex-clipboard-840bcc07-6b54-4afa-9dc7-666d09ff04f2.png`。当前结论覆盖下文旧工作台首页/明细承载描述;原业务写入规则仍按正式业务契约。
| 页面 | 操作 | 当前确认承载与布局 | 实现结论 |
|---|---|---|---|
| 预约列表 | 月/周、四类选择、当前范围搜索 | 左日历右当天安排;日期格简洁标签,最多三条 | 恢复 `AppointmentCalendarPanel`,不再打开全局工作明细弹框;待人工截图确认 |
| 预约列表 | 查看当天、未定日期 | 固定右侧区域,完整业务字段与滚动明细 | 类型仅到港、排车、还柜、计划到仓;待人工截图确认 |
| 预约列表 | 新增、审核、删除 | 本轮明确取消通用入口 | 来源业务生成记录;不在日历另建预约或改状态 |
| 计划到仓 | 调整 | 原居中弹框保留,小型时间表单 | 整柜原命令、原权限/版本/幂等与计划冻结门禁;待人工截图确认 |
| 到港、排车、还柜 | 查看与办理 | 跳原货柜/车次正式详情页 | 保留日期/月周返回上下文,无独立工作流程导航 |
| 原业务深链 | 精确来源/事项办理 | 仍使用既有居中原业务表单 | 只承接其他模块实际使用的精确入口,不恢复全局工作待办;待人工截图确认 |
2026-10-19 发布候选检查:Admin全量405文件/3372项通过,定向输入提示、弹框密度及取货组件4文件/35项通过;`pnpm build`(含类型检查)通过,仅既有AnalyticsChart大分块提示。新增日历后端直接测试及受影响SQL、API、权限合同通过,Gateway路由/部署拓扑/回滚合同与Mapper注册合同通过。
发布检查中发现的既有问题已在本候选最小修正:调拨新增箱数和取货输入提示、取货弹框密度、尾程贴标文件页签结构与对应断言;取货测试Bean及API数量断言;国内空库SQL中误放的五张海外取货表归回海外空库基线,并同步现行车次用途字段和基线表数。这些仅修改源码基线,不对运行环境执行SQL。
本次发布单元为Admin、Firstleg、Lastleg;新增两个只读日历接口复用现有Gateway通配路由,日历功能不新增数据库结构。环境发布与真实HTTP结果只维护在`docs/测试环境单机部署与配置契约.md`。人工视觉待用户在1920×1080、100%缩放下截图确认。
本轮依据:`C:/Users/BY-OY/.codex/visualizations/2026/10/14/01a13a8d-e968-7962-906b-1a514792d921/appointment-calendar-guided.html`(V13,SHA-256 `61902e410be755aa9cbf9fcfaa9d393bfa49dd8d592cd8773af5396e78c29836`)及 `docs/海外仓业务/预约工作日历V13变更说明.md`、开发协作契约。保留既有日历和居中弹框优化,不改变原业务实际执行入口。
本次密度调整依据:用户提供的主页面截图 `codex-clipboard-93984795-68cb-4082-96b3-c83189d8a357.png`、展开明细截图 `codex-clipboard-a98edd3b-82af-4f1b-989e-7d0f028e0965.png`,以及用户确认的压缩留白、日期定位、控件统一、零值弱化和统计范围说明。
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { projectContainerSchedule } from './schedule'
import type { CalendarSources, WarehouseArrival } from './scheduleApi'
const range = { from: '2026-11-01', to: '2026-12-01' }
const sources: CalendarSources = { containers: [{ containerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', vesselName: null, voyageNo: null, destinationPortName: 'Port', destinationWarehouseCode: null, arrivalAt: '2026-10-30T23:00:00', arrivalType: 'ACTUAL' }], emptyReturns: [], emptyReturnsReadable: true }
const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: 'US-W', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true }
describe('four-type schedule projection', () => {
it('keeps inconsistent authoritative plan times undated instead of hiding them with an automatic date', () => {
const events = projectContainerSchedule(sources, [{ ...warehouse, version: -1, canEdit: false }], range, true)
expect(events[0]).toMatchObject({ type: 'warehouse', date: null, subtitle: '柜内计划时间不一致,待核实' })
})
it('adds two local calendar days across months without creating a time', () => {
const events = projectContainerSchedule(sources, [warehouse], range, true)
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({ type: 'warehouse', date: '2026-11-01', time: '' })
})
it('changes the automatic date when the authoritative arrival changes and retains manual plans', () => {
const changed = { ...sources, containers: [{ ...sources.containers[0]!, arrivalAt: '2026-11-02T05:00:00' }] }
expect(projectContainerSchedule(changed, [warehouse], range, true).find(row => row.type === 'warehouse')?.date).toBe('2026-11-04')
expect(projectContainerSchedule(changed, [{ ...warehouse, plannedAt: '2026-11-08T09:30:00', version: 1 }], range, true).find(row => row.type === 'warehouse')).toMatchObject({ date: '2026-11-08', time: '09:30', subtitle: '人工维护' })
})
it('keeps manually moved containers visible when their arrival is outside this month', () => {
const events = projectContainerSchedule({ ...sources, containers: [] }, [{ ...warehouse, plannedAt: '2026-11-08T09:30:00' }], range, true)
expect(events).toHaveLength(1)
expect(events[0]?.date).toBe('2026-11-08')
})
it('does not replace the planned date with PDA actual arrival', () => {
const event = projectContainerSchedule(sources, [{ ...warehouse, plannedAt: '2026-11-05T09:00:00', actualAt: '2026-11-06T10:00:00', planCount: 1, canEdit: false }], range, true)[0]!
expect(event.date).toBe('2026-11-05')
expect(event.canEdit).toBe(false)
expect(event.fields).toContainEqual({ label: '实际到仓', value: '2026-11-06 10:00' })
})
it('shows missing dates explicitly and never uses internal IDs as display numbers', () => {
const events = projectContainerSchedule({ ...sources, containers: [{ ...sources.containers[0]!, containerNo: null, cabinetNo: null, arrivalAt: null }] }, [warehouse], range, false)
expect(events.map(row => row.date)).toEqual([null, null])
expect(events.every(row => row.sourcePath === undefined)).toBe(true)
expect(events.map(row => row.title)).toEqual(['— 到港', '— 计划到仓'])
})
it('does not invent default plans when the authoritative warehouse query failed', () => {
expect(projectContainerSchedule(sources, null, range, true)).toEqual([])
})
it('uses the actual return date after completion while preserving the deadline in details', () => {
const events = projectContainerSchedule({ ...sources, containers: [], emptyReturns: [{ containerBookingId: '1', containerNo: 'TEST-CONT', fleetSupplierName: null, actualEmptyReturnAt: '2026-11-04T10:00:00', emptyReturnWarning: { status: 'RESOLVED', deadlineAt: '2026-11-02T10:00:00' } }] }, [], range, true)
expect(events[0]).toMatchObject({ type: 'return', date: '2026-11-04', subtitle: '实际还柜' })
})
})
@@ -0,0 +1,70 @@
import { addDays } from './calendar'
import type { CalendarSources, ScheduleRange, WarehouseArrival } from './scheduleApi'
export const scheduleTypes = ['arrival', 'dispatch', 'return', 'warehouse'] as const
export type ScheduleType = typeof scheduleTypes[number]
export const scheduleLabels: Record<ScheduleType, string> = { arrival: '到港', dispatch: '排车', return: '还柜', warehouse: '计划到仓' }
export interface ScheduleEvent {
key: string
type: ScheduleType
date: string | null
time: string
title: string
subtitle?: string
fields: Array<{ label: string; value?: string | null }>
sourcePath?: string
warehouse?: WarehouseArrival
canEdit?: boolean
}
const inRange = (date: string | null, range: ScheduleRange) => !date || date >= range.from && date < range.to
const timestamp = (at: string | null) => at?.replace('T', ' ').slice(0, 16) || '—'
export function projectContainerSchedule(sources: CalendarSources, warehouses: WarehouseArrival[] | null, range: ScheduleRange, canViewContainer: boolean) {
const events: ScheduleEvent[] = []
const warehouseMap = new Map(warehouses?.map(row => [row.sourceContainerBookingId, row]) || [])
for (const row of sources.containers) {
const label = row.containerNo || row.cabinetNo || '—'
const fields = [{ label: '集装箱号', value: row.containerNo }, { label: '柜次号', value: row.cabinetNo },
{ label: '船名航次', value: [row.vesselName, row.voyageNo].filter(Boolean).join(' / ') }, { label: '目的港', value: row.destinationPortName }]
const sourcePath = canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.containerBookingId)}` : undefined
const date = row.arrivalAt?.slice(0, 10) || null
if (inRange(date, range)) events.push({ key: `arrival-${row.containerBookingId}`, type: 'arrival', date,
time: row.arrivalAt?.slice(11, 16) || '', title: `${label} 到港`, sourcePath,
subtitle: row.arrivalAt ? row.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港' : '到港日期未定',
fields: [...fields, { label: row.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港', value: timestamp(row.arrivalAt) }] })
// 读取失败时不能用默认值遮掉无法读取的人工安排。
if (warehouses === null) continue
const warehouse = warehouseMap.get(row.containerBookingId)
warehouseMap.delete(row.containerBookingId)
const canDefault = !warehouse || warehouse.version === 0 && warehouse.planCount === 0
const planned = warehouse?.plannedAt?.slice(0, 10) || (canDefault && date ? addDays(date, 2) : null)
if (inRange(planned, range)) events.push({ key: `warehouse-${row.containerBookingId}`, type: 'warehouse', date: planned,
time: warehouse?.plannedAt?.slice(11, 16) || '', title: `${label} 计划到仓`, sourcePath, warehouse,
canEdit: warehouse?.canEdit === true,
subtitle: warehouse?.plannedAt ? warehouse.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护' : canDefault ? '到港日期+2个自然日(默认)' : '柜内计划时间不一致,待核实',
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '接仓仓库', value: warehouse?.warehouseName || warehouse?.warehouseCode },
{ label: '计划到仓', value: warehouse?.plannedAt ? timestamp(warehouse.plannedAt) : planned },
{ label: '实际到仓', value: timestamp(warehouse?.actualAt || null) }] })
}
// 人工改期可能跨月,与当前月份的到港记录无关。
for (const row of warehouseMap.values()) {
const date = row.plannedAt?.slice(0, 10) || null
if (!date || !inRange(date, range)) continue
events.push({ key: `warehouse-${row.sourceContainerBookingId}`, type: 'warehouse', date, time: row.plannedAt?.slice(11, 16) || '',
title: `${row.containerNo || '—'} 计划到仓`, subtitle: row.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护', warehouse: row, canEdit: row.canEdit,
sourcePath: canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.sourceContainerBookingId)}` : undefined,
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '接仓仓库', value: row.warehouseName || row.warehouseCode },
{ label: '计划到仓', value: timestamp(row.plannedAt) }, { label: '实际到仓', value: timestamp(row.actualAt) }] })
}
for (const row of sources.emptyReturns) {
const at = row.actualEmptyReturnAt || row.emptyReturnWarning.deadlineAt
if (!at || !inRange(at.slice(0, 10), range)) continue
events.push({ key: `return-${row.containerBookingId}`, type: 'return', date: at.slice(0, 10), time: at.slice(11, 16),
title: `${row.containerNo || '—'} ${row.actualEmptyReturnAt ? '已还柜' : '还柜'}`,
subtitle: row.actualEmptyReturnAt ? '实际还柜' : row.emptyReturnWarning.status === 'OVERDUE' ? '已超过还柜截止时间' : '还柜截止提醒',
sourcePath: canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.containerBookingId)}` : undefined,
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '车队', value: row.fleetSupplierName },
{ label: '还柜截止', value: timestamp(row.emptyReturnWarning.deadlineAt) }, { label: '实际还柜', value: timestamp(row.actualEmptyReturnAt) }] })
}
return events
}
@@ -0,0 +1,50 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export interface CalendarContainer {
containerBookingId: string
cabinetNo: string | null
containerNo: string | null
vesselName: string | null
voyageNo: string | null
destinationPortName: string | null
destinationWarehouseCode: string | null
arrivalAt: string | null
arrivalType: 'ACTUAL' | 'ESTIMATED'
}
export interface CalendarReturn {
containerBookingId: string
containerNo: string | null
fleetSupplierName: string | null
actualEmptyReturnAt: string | null
emptyReturnWarning: { status: string; deadlineAt: string | null }
}
export interface CalendarSources { containers: CalendarContainer[]; emptyReturns: CalendarReturn[]; emptyReturnsReadable: boolean }
export interface WarehouseArrival {
sourceContainerBookingId: string
containerNo: string | null
warehouseName: string | null
warehouseCode: string | null
plannedAt: string | null
actualAt: string | null
version: number
planCount: number
canEdit: boolean
}
export interface ScheduleRange { from: string; to: string }
export async function getCalendarSources(range: ScheduleRange, signal?: AbortSignal) {
return unwrapApiResponse(await http.get<ApiEnvelope<CalendarSources>, ApiEnvelope<CalendarSources>>(
'/platform/v1/terminal-drayages/calendar-sources', { params: range, signal },
))
}
export async function getWarehouseArrivals(range: ScheduleRange, sourceContainerBookingIds: string[], signal?: AbortSignal) {
return unwrapApiResponse(await http.post<ApiEnvelope<WarehouseArrival[]>, ApiEnvelope<WarehouseArrival[]>>(
'/platform/v1/last-leg/appointment-calendar/warehouse-arrivals/query', { ...range, sourceContainerBookingIds }, { signal },
))
}
export interface WarehouseArrivalChange { estimatedWarehouseArrivalAt: string; expectedVersion: number }
export async function saveWarehouseArrival(containerId: string, body: WarehouseArrivalChange, key: string) {
return unwrapApiResponse(await http.put<ApiEnvelope<unknown>, ApiEnvelope<unknown>>(
`/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(containerId)}/expected-arrival`, body,
{ headers: { 'Idempotency-Key': key } },
))
}
@@ -7,6 +7,13 @@ function trip(tripNo: string, plannedDepartureAt: string, originTimezone = 'Amer
return { sourceType: 'TRIP_PLAN', tripNo, 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('counts a shared trip once and excludes cancelled arrangements', () => {
const shared = trip('TR-SHARED', '2026-10-10T12:00:00Z')
const cancelled = { ...trip('TR-CANCELLED', '2026-10-10T12:00:00Z'), status: 'CANCELLED' }
const result = projectTripCalendar({ records: [shared, shared, cancelled], unresolvedRecords: [] }, range)
expect(result.events).toHaveLength(1)
expect(result.events[0]).toMatchObject({ type: 'dispatch', title: 'TR-SHARED 排车' })
})
it('queries one padded UTC window for 42 location dates without frontend page scanning', async () => {
const localRange = {from:'2026-09-28',to:'2026-11-09'}
expect(tripQueryWindow(localRange)).toEqual({from:'2026-09-27T10:00:00.000Z',to:'2026-11-09T14:00:00.000Z'})
@@ -1,5 +1,5 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { CalendarEvent } from './AppointmentCalendarPanel.vue'
import type { ScheduleEvent } from './schedule'
export interface TripCalendarEntry {
sourceType: 'TRIP_PLAN'
@@ -54,9 +54,10 @@ function localStamp(value: string | null, zone: string | null) {
} catch { return null }
}
export function projectTripCalendar(result: TripCalendarResult, range: { from: string; to: string }) {
const events: CalendarEvent[] = [], unresolved = [...result.unresolvedRecords]
const events: ScheduleEvent[] = [], unresolved = [...result.unresolvedRecords]
const labels: Record<string, string> = { PLANNED: '计划中', IN_TRANSIT: '运输中', COMPLETED: '货物已结案', CANCELLED: '已取消' }
for (const row of result.records) {
for (const row of new Map(result.records.map(row => [row.tripNo, row])).values()) {
if (row.status === 'CANCELLED') continue
// Executed work stays on its actual local date even if the plan changes later.
const occurrence = localStamp(row.departedAt ?? row.plannedDepartureAt, row.originTimezone)
if (row.originCountryCode !== 'US' || !occurrence) {
@@ -65,8 +66,8 @@ 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 driverEnd = localStamp(row.expectedDriverEndAt, row.originTimezone)
events.push({ key: `trip-${row.tripNo}`, type: 'trip', date: occurrence.date, time: occurrence.time,
title: `${row.tripNo} ${row.departedAt ? '实际发车' : '用车计划'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '运输计划,不代表收货方预约已确认',
events.push({ key: `trip-${row.tripNo}`, type: 'dispatch', date: occurrence.date, time: occurrence.time,
title: `${row.tripNo} ${row.departedAt ? '实际发车' : '排车'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '海外仓送往 FBA/客户仓的车辆安排',
sourcePath: `/overseas-master-data/trips/${encodeURIComponent(row.tripNo)}`,
fields: [
{ label: '车次状态', value: labels[row.status] || '待核实' },
@@ -61,12 +61,12 @@ watch(page, load); onMounted(load); onBeforeUnmount(() => { sequence++ })
<template #notices><LoadErrorState v-if="error && !open" title="待取货需求加载失败" :message="error" @retry="load" /></template>
<template #warehouse="{ row }">{{ row.warehouseName || '海外仓' }} · {{ row.warehouseCode || row.warehouseId }}</template>
</DataTable>
<AppDialog v-model="open" :before-close="close" title="安排仓储转运取货" width="780px" :confirm-text="pending ? '重试原安排请求' : '创建取货车次'" :confirm-loading="busy" @confirm="save">
<AppDialog v-model="open" class="admin-density-dialog" :before-close="close" title="安排仓储转运取货" width="780px" :confirm-text="pending ? '重试原安排请求' : '创建取货车次'" :confirm-loading="busy" @confirm="save">
<p>已选 {{ snapshot.length }} 单:{{ snapshot.map(row => row.transferNo).join('、') }}</p>
<el-form label-position="top" class="pickup-plan-form" :disabled="busy || !!pending">
<el-form-item label="计划取货发车时间" required><el-date-picker v-model="departure" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item label="司机预计任务结束时间" required><el-date-picker v-model="end" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item label="调度备注"><el-input v-model="remark" maxlength="1000" /></el-form-item>
<el-form-item label="调度备注"><el-input v-model="remark" maxlength="1000" placeholder="请输入调度备注(选填)" /></el-form-item>
</el-form>
<el-alert v-if="error" :title="error" type="error" :closable="false" />
</AppDialog>
@@ -119,12 +119,12 @@ onBeforeUnmount(() => { sequence++ })
<template #status="{ row }">{{ pickupStatusLabels[row.status] || row.status }}</template><template #actual="{ row }">{{ row.actualPackages ?? '待取货' }}</template>
<template #evidence="{ row }"><div v-for="file in row.evidence" :key="file.fileId"><el-button link @click="download(row as PickupTask, file)">{{ file.fileName }}</el-button></div></template>
</DataTable>
<AppDialog v-model="open" :before-close="close" title="确认本单取货" width="680px" :confirm-loading="busy" :confirm-text="pending ? '重试原取货确认' : '确认已取货'" @confirm="confirmPickup">
<AppDialog v-model="open" class="admin-density-dialog" :before-close="close" title="确认本单取货" width="680px" :confirm-loading="busy" :confirm-text="pending ? '重试原取货确认' : '确认已取货'" @confirm="confirmPickup">
<p>{{ selected?.transferNo }} · {{ selected?.pickupAddress }}</p>
<el-form label-position="top" :disabled="busy || !!pending">
<el-form-item label="实提箱数" required><el-input-number v-model="actual" :min="1" :max="5000" :precision="0" /></el-form-item>
<el-form-item label="实提箱数" required><el-input-number v-model="actual" :min="1" :max="5000" :precision="0" placeholder="请输入实提箱数" /></el-form-item>
<el-form-item label="取货凭证" required><div><input type="file" accept="image/png,image/jpeg,application/pdf" :disabled="busy || !!pending || evidence.length >= 10" @change="upload"><p v-for="file in evidence" :key="file.fileId">{{ file.fileName }}</p></div></el-form-item>
<el-form-item label="取货备注"><el-input v-model="remark" type="textarea" maxlength="1000" /></el-form-item>
<el-form-item label="取货备注"><el-input v-model="remark" type="textarea" maxlength="1000" placeholder="请输入取货备注(选填)" /></el-form-item>
</el-form><el-alert v-if="error" :title="error" type="error" :closable="false" />
</AppDialog>
</section>
@@ -159,7 +159,7 @@ onBeforeUnmount(() => { generation++ })
</DataTable></AppDialog>
</template>
<AppDialog class="admin-density-dialog" v-model="increaseOpen" title="增加预报箱数" width="520px" :show-footer="false">
<el-form label-width="100px"><el-form-item label="新增箱数"><el-input-number v-model="additionalCount" :min="1" :max="Math.max(1, 5000 - forecastSnapshot)" :precision="0" :disabled="increasing" /></el-form-item></el-form>
<el-form label-width="100px"><el-form-item label="新增箱数"><el-input-number v-model="additionalCount" placeholder="请输入新增箱数" :min="1" :max="Math.max(1, 5000 - forecastSnapshot)" :precision="0" :disabled="increasing" /></el-form-item></el-form>
<p>保留原箱唛,仅补生成新增箱唛;箱内SKU和件数以PDA清点为准。</p>
<el-button :disabled="increasing" @click="increaseOpen = false">取消</el-button><el-button type="primary" :loading="increasing" @click="saveIncreaseForecast">确认增加</el-button>
</AppDialog>
@@ -284,11 +284,12 @@ onMounted(load)
<div v-for="field in basicFields" :key="field.label" :class="{ wide: field.wide }"><dt>{{ field.label }}</dt><dd :class="{ 'detail-field-value--business': isSingleLineBusinessField(field), 'package-difference--nonzero': isNonzeroDifference(field) }" :title="isSingleLineBusinessField(field) ? String(field.value) : undefined">{{ field.value }}</dd></div>
</dl>
</el-tab-pane>
<el-tab-pane v-if="access.can(permission.relabelOrderList) && (detail?.order.relabelApplications?.length || detail?.order.relabelOrderNo)" name="relabel-files" label="贴标文件">
<template v-if="activeTab === 'relabel-files'">
<el-tab-pane v-if="access.can(permission.relabelOrderList) && (detail?.order.relabelApplications?.length || detail?.order.relabelOrderNo)" name="relabel-files">
<template #label><span class="density-tab-icon" aria-hidden="true">📎</span><span>贴标文件</span></template>
<section v-if="activeTab === 'relabel-files'" aria-label="贴标文件">
<RelabelDocumentsPanel v-for="application in detail?.order.relabelApplications || []" :key="application.relabelNo" :relabel-no="application.relabelNo" />
<RelabelDocumentsPanel v-if="!detail?.order.relabelApplications?.length && detail?.order.relabelOrderNo" :relabel-no="detail.order.relabelOrderNo" />
</template>
</section>
</el-tab-pane>
<el-tab-pane name="cargo"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>货物信息</span></template><section class="table-panel"><WarehouseTransferReceiptPanel v-if="detail?.order.orderType === 'WAREHOUSE_TRANSFER' && activeTab === 'cargo'" :customer-id="String(detail.order.customerId || '')" :transfer-no="String(detail.order.sourceOrderNo || '')" @summary="receiptSummary = $event" /><DataTable v-else-if="detail?.order.orderType !== 'WAREHOUSE_TRANSFER'" :data="cargo" :columns="cargoColumns" column-settings-mode="hide" row-key="id" :empty-text="isReturnOrder ? '暂无客户预报货物明细' : '暂无货物明细'"></DataTable></section></el-tab-pane>
<el-tab-pane v-if="detail?.order.orderType === 'RETURN_RELABEL'" name="return-receipt"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>退货签收与箱内清点</span></template>
+18 -23
View File
@@ -180,31 +180,26 @@ describe('approved Admin density ownership', () => {
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 appointment dialog content scrollable with fixed controls and unclipped calendar days', () => {
const declarations = (file: string, selector: string) => {
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
const panel = parse(read('modules/appointment-calendar/AppointmentCalendarPanel.vue')).descriptor
expect(view.template!.content).toContain('<AppointmentCalendarPanel')
expect(view.scriptSetup!.content).not.toMatch(/getWorkSummary|getWorkCalendar|getWorkItems/)
const declarations = (selector: string) => {
const values: Record<string, string> = {}
const css = parse(read(`modules/appointment-calendar/${file}`)).descriptor.styles.map(style => style.content).join('\n')
cssRoot(css).walkRules(selector, rule => { rule.walkDecls(decl => { values[decl.prop] = decl.value }) })
cssRoot(panel.styles.map(style => style.content).join('\n')).walkRules(selector, rule => {
if (rule.parent?.type !== 'root') return
rule.walkDecls(decl => { values[decl.prop] = decl.value })
})
return values
}
const panel = declarations('AppointmentCalendarView.vue', '.work-panel')
expect(panel.position).toBeUndefined()
expect(panel['min-height']).toBe('0')
expect(declarations('AppointmentCalendarView.vue', ':global(.work-dialog.el-dialog:not(.is-fullscreen))').height).toBe('88dvh')
expect(declarations('AppointmentCalendarView.vue', ':global(.work-dialog .el-dialog__body)').overflow).toBe('hidden')
expect(declarations('AppointmentCalendarView.vue', '.work-layout')['grid-template-columns']).toBeUndefined()
expect(declarations('AppointmentCalendarView.vue', '.panel-body')).toMatchObject({ 'overflow-y': 'auto', 'min-height': '0' })
expect(declarations('AppointmentCalendarView.vue', '.panel-header').flex).toBe('none')
expect(declarations('AppointmentCalendarView.vue', '.panel-footer').flex).toBe('none')
expect(declarations('AppointmentCalendarView.vue', '.work-row-action .el-button')).toMatchObject({ 'line-height': '20px', 'white-space': 'normal', height: 'auto' })
expect(declarations('AppointmentCalendarView.vue', '.work-row-action :deep(.el-button > span)')).toMatchObject({ 'line-height': 'inherit', 'white-space': 'normal', 'overflow-wrap': 'anywhere', 'min-width': '0', 'max-width': '100%' })
expect(declarations('WorkCalendarPanel.vue', '.calendar-toolbar')['justify-content']).toBe('flex-start')
expect(declarations('AppointmentCalendarView.vue', '.work-inbox').border).toBeUndefined()
const day = declarations('WorkCalendarPanel.vue', '.work-day')
expect(day.height).toBe('146px')
expect(day['min-height']).toBe(day.height)
expect(day['max-height']).toBe(day.height)
expect(day.overflow).not.toBe('hidden')
expect(declarations('.appointment-calendar')['grid-template-columns']).toBe('minmax(0, 1fr) 360px')
expect(declarations('.calendar-day').height).toBe('146px')
expect(declarations('.week-grid .calendar-day').height).toBe('270px')
expect(declarations('.detail-events')['overflow-y']).toBe('auto')
expect(declarations('.detail-event dd')['overflow-wrap']).toBe('anywhere')
expect(panel.template!.content).toContain('.slice(0, 3)')
expect(panel.template!.content).not.toContain('新增预约')
})
it('shares list status and category tab styles across registered list consumers', () => {
for (const file of [
@@ -372,7 +367,7 @@ describe('approved Admin density ownership', () => {
visit(node.children)
} }
visit(ast.children)
expect(panes).toBe(file === 'LastMileOrderDetailView' ? 6 : 4)
expect(panes).toBe(file === 'LastMileOrderDetailView' ? 7 : 4)
}
})