fix(appointment): restore overlay panel and compact calendar controls
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { inject, onBeforeUnmount } from 'vue'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
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 }))
|
||||
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() }))
|
||||
@@ -13,8 +15,12 @@ function render() {
|
||||
return mount(AppointmentCalendarView, { global: { stubs: {
|
||||
AdminPage: { template: '<main><slot /></main>' },
|
||||
ElButton: { template: '<button><slot /></button>' },
|
||||
WorkCalendarPanel: { name: 'WorkCalendarPanel', props: ['anchor', 'mode', 'flow', 'types', 'calendar', 'failed', 'reference'], template: '<div data-calendar />' },
|
||||
WorkDetailPanel: { name: 'WorkDetailPanel', props: ['itemKey', 'initialAction', 'canLeave'], template: '<div data-detail>{{ itemKey }}</div>' },
|
||||
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,
|
||||
@@ -22,7 +28,7 @@ function render() {
|
||||
}
|
||||
describe('appointment work calendar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); routing.query = { workAnchor: '2026-10-15' }; routing.canReadSource = true
|
||||
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)
|
||||
@@ -32,10 +38,43 @@ describe('appointment work calendar', () => {
|
||||
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()
|
||||
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()
|
||||
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('.panel-tools button:first-child').trigger('click')
|
||||
expect(wrapper.get('.work-panel').classes()).toContain('is-expanded')
|
||||
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)
|
||||
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()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
@@ -48,6 +48,22 @@ const heading = computed(() => {
|
||||
})
|
||||
const scopeDescription = computed(() => scope.value?.kind === 'day' ? '按所选日期、流程与类型查询,包含已完成事项' : scope.value?.kind === 'arrivals' ? '独立到港来源,不计工作量;来源未提供流程筛选,展示该日全部参考' : '跨月份、流程与类型,包含未预约和未定日期事项')
|
||||
const panelOpen = computed(() => !!scope.value || !!detailKey.value || !!sourceLink.value)
|
||||
const panelExpanded = ref(false), panelHeading = ref<HTMLElement | null>(null), 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')
|
||||
let panelTrigger: HTMLElement | null = null
|
||||
watch(panelOpen, async open => {
|
||||
if (open) {
|
||||
panelTrigger = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
await nextTick(); panelHeading.value?.focus()
|
||||
} else {
|
||||
panelExpanded.value = false
|
||||
await nextTick(); if (panelTrigger?.isConnected) panelTrigger.focus()
|
||||
}
|
||||
})
|
||||
watch([detailKey, sourceLinkKey, scope, page], () => { if (panelBody.value) panelBody.value.scrollTop = 0 }, { flush: 'post' })
|
||||
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 }
|
||||
@@ -170,44 +186,89 @@ onBeforeUnmount(() => { ++calendarRequest; ++summaryRequest; ++itemsRequest; ++r
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage class="admin-density appointment-work" spacing="compact">
|
||||
<header class="work-heading"><h2 class="density-detail-title">预约工作日历</h2><span>已开船事项 · 中转与直送统一查看</span></header>
|
||||
<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>{{ 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>
|
||||
<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 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>
|
||||
<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" :class="{ 'has-panel': panelOpen }">
|
||||
<WorkCalendarPanel :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 })" />
|
||||
<aside v-if="panelOpen" class="work-panel" aria-label="工作明细与事项操作">
|
||||
<div class="panel-nav"><button v-if="detailKey || sourceLink" type="button" @click="back">‹ {{ origins.length ? '返回原事项' : '返回工作明细' }}</button><button type="button" @click="closePanel">关闭 ×</button></div>
|
||||
<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>
|
||||
<h3>{{ heading }}</h3><p class="scope-description">{{ scopeDescription }}</p>
|
||||
<template v-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>
|
||||
<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>
|
||||
<LoadErrorState v-if="itemsError" title="工作明细读取失败" :message="itemsError.message" :loading="itemsLoading" @retry="loadItems"><template #details><ApiErrorMeta :details="itemsError" /></template></LoadErrorState>
|
||||
<p v-if="itemsLoading">正在读取明细…</p>
|
||||
<template v-else-if="items && !itemsError"><p>共 {{ items.total }} 项</p><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="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"><button type="button" class="item-title" @click="openItem(item.workItemKey)">{{ item.title }}</button><p>{{ typeLabels[item.type] }} · {{ workStatus(item) }} · {{ workDate(item) }} · {{ item.timezone || '地点时区待核实' }}</p><p>{{ item.destinationName || item.originName || '地点待核实' }} · {{ flowLabels[item.flow] }} · {{ item.owner?.name || '待分派' }}</p><p v-if="item.followupDate">跟进 {{ item.followupDate }}</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><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></template>
|
||||
</template>
|
||||
<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>{{ 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>
|
||||
<aside v-if="panelOpen" class="work-panel" :class="{ 'is-expanded': panelExpanded }" aria-label="工作明细与事项操作">
|
||||
<header class="panel-header">
|
||||
<div class="panel-nav"><button v-if="detailKey || sourceLink" type="button" @click="back">‹ {{ origins.length ? '返回原事项' : '返回工作明细' }}</button><div class="panel-tools"><button type="button" :aria-expanded="panelExpanded" @click="panelExpanded = !panelExpanded">{{ panelExpanded ? '收起面板' : '展开面板' }}</button><button type="button" aria-label="关闭工作面板" @click="closePanel">关闭 ×</button></div></div>
|
||||
<h3 ref="panelHeading" tabindex="-1">{{ panelTitle }}</h3>
|
||||
<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>
|
||||
<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="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>{{ typeLabels[item.type] }} · {{ workDate(item) }} · {{ item.timezone || '地点时区待核实' }}</p><p>{{ item.destinationName || item.originName || '地点待核实' }} · {{ flowLabels[item.flow] }} · {{ item.owner?.name || '待分派' }}<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>
|
||||
<footer v-if="listVisible && items && !itemsError && !itemsLoading" 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>
|
||||
</aside>
|
||||
</div>
|
||||
</AdminPage>
|
||||
</template>
|
||||
<style scoped>
|
||||
.work-heading, .work-inbox, .work-search { display: flex; align-items: center; gap: var(--admin-space-5); flex-wrap: wrap; }.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-3) var(--admin-space-5); border: 1px solid var(--cc-border); background: var(--admin-surface); }.work-inbox button { display: flex; gap: var(--admin-space-3); align-items: center; }.work-search input { width: 320px; }
|
||||
.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); }.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 { outline: 2px solid var(--el-color-primary); outline-offset: 1px; }
|
||||
.work-layout { display: grid; grid-template-columns: minmax(0, 1fr); align-items: start; gap: var(--admin-space-5); }.work-layout.has-panel { grid-template-columns: minmax(0, 1fr) minmax(420px, 490px); }.work-panel { border: 1px solid var(--cc-border); padding: var(--admin-space-5); background: var(--admin-surface); min-width: 0; overflow-wrap: anywhere; max-height: 900px; overflow-y: auto; }.panel-nav { display: flex; justify-content: space-between; gap: var(--admin-space-3); position: sticky; top: 0; background: var(--admin-surface); z-index: 1; }.panel-nav > button:last-child { margin-left: auto; }.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-5) 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-3); }.work-row-content p { margin: var(--admin-space-2) 0; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }.work-row-content .item-title { border: 0; padding: 0; text-align: left; min-height: 24px; color: var(--el-text-color-primary); }.work-warning { color: var(--el-color-warning-dark-2); }.work-pagination, .bucket-buttons { display: flex; align-items: center; justify-content: center; gap: var(--admin-space-3); flex-wrap: wrap; margin-top: var(--admin-space-3); }.bucket-buttons { justify-content: flex-start; }.bucket-buttons button { font-size: var(--admin-font-caption); }
|
||||
.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-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; }
|
||||
.work-panel { position: absolute; top: 0; right: 0; z-index: 4; width: min(680px, 100%); height: min(720px, calc(100dvh - 260px)); min-height: 320px; box-sizing: border-box; display: flex; flex-direction: column; border: 1px solid var(--cc-border); background: var(--admin-surface); box-shadow: var(--el-box-shadow-light); overflow-wrap: anywhere; }
|
||||
.work-panel.is-expanded { width: 100%; }
|
||||
.panel-header { flex: none; padding: var(--admin-space-3) var(--admin-space-5); border-bottom: 1px solid var(--cc-border); }
|
||||
.panel-nav, .panel-tools { display: flex; align-items: center; gap: var(--admin-space-3); }
|
||||
.panel-tools { margin-left: auto; }
|
||||
.panel-header h3 { font-size: var(--admin-font-section); 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-5) var(--admin-space-3); }
|
||||
.panel-footer { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); flex-wrap: wrap; flex: none; padding: var(--admin-space-3) var(--admin-space-5); border-top: 1px solid var(--cc-border); 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); }
|
||||
.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-secondary); font-size: var(--admin-font-caption); }
|
||||
.work-row-content p { margin: var(--admin-space-1) 0; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); line-height: 1.6; }
|
||||
.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); }
|
||||
.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%); }
|
||||
@media(max-width: 1100px) { .work-layout.has-panel { grid-template-columns: minmax(0, 1fr); } }
|
||||
</style>
|
||||
|
||||
@@ -2,9 +2,19 @@
|
||||
|
||||
本轮依据:`C:/Users/BY-OY/.codex/visualizations/2026/10/14/01a13a8d-e968-7962-906b-1a514792d921/appointment-calendar-guided.html`(v10)与预约工作日历开发协作契约 v1。下表取代旧三视图、整行选货和日历辅助方案;不改变原业务实际执行入口。
|
||||
|
||||
本次密度调整依据:用户提供的主页面截图 `codex-clipboard-93984795-68cb-4082-96b3-c83189d8a357.png`、展开明细截图 `codex-clipboard-a98edd3b-82af-4f1b-989e-7d0f028e0965.png`,以及用户确认的压缩留白、日期定位、控件统一、零值弱化和统计范围说明。
|
||||
|
||||
| 本次操作 | 证据与现状 | 调整结论 |
|
||||
|---|---|---|
|
||||
| 流程与工具栏分组 | 用户后续流程下拉、日期工具栏及上下操作区截图(`115df974`、`d217350b`、`80dd9fa1`) | 流程改为四个直接切换按钮;全局待办、流程、日期与类型筛选收进连续白底工作区,各行靠左,保留全局与区间统计边界 |
|
||||
| 日历浏览与日期选择 | 主页面截图;原型 `renderCalendar()`、`.day.selected`、`.todaymark` | 缩小日格空白,今天与选择态分别标识;零项日期只显示日期;显示实际查询起止日期,保留加载失败状态 |
|
||||
| 日期/类型/全局明细 | 展开截图中日历被挤窄;原型第 11 行 `.workspace`、`.panel` 为右侧覆盖布局 | 恢复覆盖式侧边工作面板,不压缩日历;增加展开/收起,标题和关闭固定,正文滚动,分页固定;仍保留左侧业务操作 |
|
||||
| 详情与编辑 | 原型 `renderTask()`、`followForm()` | 保留原侧边详情/表单和原业务跳转;关闭、切换和返回仍经过未保存保护,扩宽不重建表单 |
|
||||
| 今日需跟进 | 仅核对 `AppointmentWorkPolicy.followToday` 和 `AppointmentWorkQueryService.summary` | 保留服务端统计、接口参数及前端数字,不在本次视觉调整中改口径 |
|
||||
|
||||
| 页面/操作 | 原型证据 | 承载与布局 | 实现入口及核对范围 |
|
||||
|---|---|---|---|
|
||||
| 日历首屏、五类计数 | `.calendar`、`renderCalendar()` | 月/周日历,固定高度日格,五类勾选与类型计数;到港参考单列且不计工作 | `AppointmentCalendarView.vue`、`WorkCalendarPanel.vue`;服务端聚合,实际日期优先由服务端给出 |
|
||||
| 日历首屏、五类计数 | `.calendar`、`renderCalendar()` | 月/周日历,紧凑日格随内容自然增高,五类勾选与类型计数;到港参考单列且不计工作 | `AppointmentCalendarView.vue`、`WorkCalendarPanel.vue`;服务端聚合,实际日期优先由服务端给出 |
|
||||
| 全部待办、今日、超期、未定日期、复核 | `.inboxbar`、`backlog()`、`renderList()` | 日历上方全局入口;侧边分页明细,操作在左,范围明确 | `workApi.ts`;summary 不带月历条件,全关图层不影响全局查询 |
|
||||
| 日期、类型与本柜连续工作 | `openDay()`、`renderList()` 的 container 模式 | 同一侧边面板;确认路线后回本柜未完成事项 | `WorkDetailPanel.vue`;保存刷新摘要、日历和当前范围 |
|
||||
| 下一步、前置事项与返回 | `nextAction()`、`renderTask()`、`.origin-context` | 主按钮直接处理当前下一步;前置项保留原事项返回入口 | 动作 code/target 白名单,重新读取目标资格;原业务执行仍回原入口 |
|
||||
|
||||
@@ -1,9 +1,49 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { expect, it } from 'vitest'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import WorkCalendarPanel from './WorkCalendarPanel.vue'
|
||||
import { workCalendar } from './work.test-support'
|
||||
import { workTypes } from './workApi'
|
||||
function render() { return mount(WorkCalendarPanel, { props: { anchor: '2026-10-15', mode: 'month', flow: '', types: [...workTypes], calendar: workCalendar, loading: false, failed: false, reference: false, arrivals: [], referenceReady: false } }) }
|
||||
afterEach(() => vi.useRealTimers())
|
||||
it('offers all four flows directly and emits the original filter values with an exclusive selection', async () => {
|
||||
const wrapper = render()
|
||||
const buttons = wrapper.findAll('.flow-controls button')
|
||||
expect(buttons.map(button => button.text())).toEqual(['全部流程', '海外仓中转', '整柜直送', '路线待确认'])
|
||||
expect(wrapper.find('select').exists()).toBe(false)
|
||||
for (const [index, flow] of (['', 'TRANSFER', 'DIRECT', 'UNKNOWN'] as const).entries()) {
|
||||
await buttons[index]!.trigger('click')
|
||||
expect(wrapper.emitted('flow')?.at(-1)).toEqual([flow])
|
||||
await wrapper.setProps({ flow })
|
||||
expect(wrapper.findAll('.flow-controls button[aria-pressed=true]')).toHaveLength(1)
|
||||
expect(buttons[index]!.attributes('aria-pressed')).toBe('true')
|
||||
}
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('distinguishes today from the selected day and type without changing the selected scope', async () => {
|
||||
vi.useFakeTimers(); vi.setSystemTime(new Date('2026-10-15T12:00:00'))
|
||||
const wrapper = render()
|
||||
await wrapper.setProps({ selectedDate: '2026-10-16' })
|
||||
expect(wrapper.get('[data-date="2026-10-15"] .day-heading').attributes('aria-current')).toBe('date')
|
||||
expect(wrapper.get('[data-date="2026-10-15"] .day-heading').attributes('aria-pressed')).toBe('false')
|
||||
expect(wrapper.get('[data-date="2026-10-16"]').classes()).toContain('is-selected')
|
||||
await wrapper.setProps({ selectedDate: '2026-10-15', selectedType: 'DELIVERY' })
|
||||
expect(wrapper.get('[data-date="2026-10-15"] [data-type="DELIVERY"]').attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper.get('[data-date="2026-10-15"] [data-type="PICKUP"]').attributes('aria-pressed')).toBe('false')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('shows the actual cross-month interval and hides empty-day zeros while keeping dates clickable', async () => {
|
||||
const wrapper = render()
|
||||
expect(wrapper.get('.calendar-counts').text()).toContain('2026-09-28 ~ 2026-11-08')
|
||||
const empty = wrapper.get('[data-date="2026-10-01"] .day-heading')
|
||||
expect(empty.text()).toBe('1')
|
||||
expect(empty.attributes('disabled')).toBeUndefined()
|
||||
await empty.trigger('click'); expect(wrapper.emitted('day')).toEqual([['2026-10-01']])
|
||||
expect(wrapper.get('footer').text()).toContain('未定日期请从上方待办查看')
|
||||
await wrapper.setProps({ mode: 'week' })
|
||||
expect(wrapper.findAll('.work-day')).toHaveLength(7)
|
||||
expect(wrapper.get('.view-switch button:last-child').attributes('aria-pressed')).toBe('true')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('renders server type counts instead of individual event cards and opens exact date/type details', async () => {
|
||||
const wrapper = render()
|
||||
expect(wrapper.findAll('.work-day')).toHaveLength(42)
|
||||
|
||||
@@ -1,61 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { calendarDates, type CalendarMode } from './calendar'
|
||||
import { calendarDates, dateKey, type CalendarMode } from './calendar'
|
||||
import { workTypes, type WorkCalendar, type WorkFlow, type WorkType } from './workApi'
|
||||
import { typeLabels } from './workPresentation'
|
||||
import { flowLabels, typeLabels } from './workPresentation'
|
||||
import type { ArrivalCalendarEntry } from './api'
|
||||
const props = defineProps<{ anchor: string; mode: CalendarMode; flow: WorkFlow | ''; types: WorkType[]; calendar: WorkCalendar | null; loading: boolean; failed: boolean; reference: boolean; arrivals: ArrivalCalendarEntry[]; referenceReady: boolean }>()
|
||||
const props = defineProps<{ anchor: string; mode: CalendarMode; flow: WorkFlow | ''; types: WorkType[]; calendar: WorkCalendar | null; loading: boolean; failed: boolean; reference: boolean; arrivals: ArrivalCalendarEntry[]; referenceReady: boolean; selectedDate?: string; selectedType?: WorkType }>()
|
||||
const emit = defineEmits<{ anchor: [offset: number]; today: []; mode: [value: CalendarMode]; flow: [value: WorkFlow | '']; types: [value: WorkType[]]; reference: [value: boolean]; day: [date: string, type?: WorkType]; arrivals: [date: string] }>()
|
||||
const days = computed(() => calendarDates(props.anchor, props.mode))
|
||||
const today = dateKey(new Date())
|
||||
const flows: Array<{ value: WorkFlow | ''; label: string }> = [{ value: '', label: '全部流程' }, { value: 'TRANSFER', label: flowLabels.TRANSFER }, { value: 'DIRECT', label: flowLabels.DIRECT }, { value: 'UNKNOWN', label: flowLabels.UNKNOWN }]
|
||||
const aggregates = computed(() => new Map(props.calendar?.days.map(day => [day.date, day]) || []))
|
||||
const heading = computed(() => props.mode === 'month' ? `${props.anchor.slice(0, 4)}年${Number(props.anchor.slice(5, 7))}月` : `${days.value[0]} — ${days.value.at(-1)}`)
|
||||
function toggle(type: WorkType, checked: boolean) { emit('types', checked ? [...props.types, type] : props.types.filter(value => value !== type)) }
|
||||
function arrivalCount(date: string) { return props.arrivals.filter(row => row.arrivalAt.slice(0, 10) === date).length }
|
||||
</script>
|
||||
<template>
|
||||
<section class="work-calendar" aria-label="预约工作日历" :aria-busy="loading">
|
||||
<section class="work-calendar" :class="{ 'week-view': mode === 'week' }" aria-label="预约工作日历" :aria-busy="loading">
|
||||
<div v-if="$slots.inbox" class="global-work"><slot name="inbox" /></div>
|
||||
<header class="calendar-toolbar">
|
||||
<label>流程 <select aria-label="日历流程" :value="flow" @change="emit('flow', ($event.target as HTMLSelectElement).value as WorkFlow | '')"><option value="">全部流程</option><option value="TRANSFER">海外仓中转</option><option value="DIRECT">整柜直送</option><option value="UNKNOWN">路线待确认</option></select></label>
|
||||
<div class="period-controls"><button type="button" :aria-pressed="mode === 'month'" @click="emit('mode', 'month')">月</button><button type="button" :aria-pressed="mode === 'week'" @click="emit('mode', 'week')">周</button><button type="button" aria-label="上一时段" @click="emit('anchor', -1)">‹</button><strong>{{ heading }}</strong><button type="button" aria-label="下一时段" @click="emit('anchor', 1)">›</button><button type="button" @click="emit('today')">今天</button></div>
|
||||
<div class="flow-controls" role="group" aria-label="日历流程"><span class="muted">流程</span><button v-for="option in flows" :key="option.value" type="button" :data-flow="option.value" :aria-pressed="flow === option.value" @click="emit('flow', option.value)">{{ option.label }}</button></div>
|
||||
<div class="period-controls"><div class="view-switch" aria-label="日历视图"><button type="button" :aria-pressed="mode === 'month'" @click="emit('mode', 'month')">月</button><button type="button" :aria-pressed="mode === 'week'" @click="emit('mode', 'week')">周</button></div><button type="button" aria-label="上一时段" @click="emit('anchor', -1)">‹</button><strong>{{ heading }}</strong><button type="button" aria-label="下一时段" @click="emit('anchor', 1)">›</button><button type="button" title="定位到浏览器当前日期" @click="emit('today')">今天</button></div>
|
||||
<span class="muted">作业地点当地时间</span>
|
||||
</header>
|
||||
<div class="work-layers"><span class="muted">显示类型</span><label v-for="type in workTypes" :key="type" :data-type="type"><input type="checkbox" :checked="types.includes(type)" @change="toggle(type, ($event.target as HTMLInputElement).checked)">{{ typeLabels[type] }}</label><label><input type="checkbox" :checked="reference" @change="emit('reference', ($event.target as HTMLInputElement).checked)">到港参考</label></div>
|
||||
<div class="calendar-counts" aria-live="polite">
|
||||
<span v-if="loading">正在读取工作统计…</span><span v-else-if="failed">工作统计加载失败</span><span v-else-if="!types.length">未勾选工作类型,全局待办仍可查看</span>
|
||||
<template v-else-if="calendar"><strong>当前显示区间 · {{ calendar.total }} 项工作</strong><span>待安排 {{ calendar.counts.pending }}</span><span>已落实 {{ calendar.counts.confirmed }}</span><span>无需预约 {{ calendar.counts.noBookingRequired }}</span><span>需复核 {{ calendar.counts.review }}</span><span>已完成 {{ calendar.counts.completed }}</span></template>
|
||||
<template v-else-if="calendar"><strong>当前显示区间 {{ calendar.from }} ~ {{ calendar.to }} · {{ calendar.total }} 项工作</strong><span>待安排 {{ calendar.counts.pending }}</span><span>已落实 {{ calendar.counts.confirmed }}</span><span>无需预约 {{ calendar.counts.noBookingRequired }}</span><span>需复核 {{ calendar.counts.review }}</span><span>已完成 {{ calendar.counts.completed }}</span></template>
|
||||
</div>
|
||||
<div class="weekdays"><span v-for="day in ['一', '二', '三', '四', '五', '六', '日']" :key="day">周{{ day }}</span></div>
|
||||
<div class="calendar-days">
|
||||
<div v-for="date in days" :key="date" class="work-day" :class="{ outside: mode === 'month' && !date.startsWith(anchor.slice(0, 7)) }" :data-date="date">
|
||||
<button class="day-heading" type="button" :disabled="loading || failed || !types.length" :aria-label="`${date} 工作明细`" @click="emit('day', date)"><span>{{ Number(date.slice(8)) }}</span><span>{{ !loading && !failed && types.length ? `${aggregates.get(date)?.total ?? 0} 项` : '—' }}</span></button>
|
||||
<div class="type-counts"><template v-if="!loading && !failed && types.length"><button v-for="type in workTypes.filter(type => types.includes(type) && (aggregates.get(date)?.typeCounts[type] ?? 0) > 0)" :key="type" type="button" :data-type="type" :aria-label="`${date} ${typeLabels[type]} ${aggregates.get(date)?.typeCounts[type]} 项`" @click="emit('day', date, type)"><span>{{ typeLabels[type] }}</span><b>{{ aggregates.get(date)?.typeCounts[type] }}</b></button></template></div>
|
||||
<div v-for="date in days" :key="date" class="work-day" :class="{ outside: mode === 'month' && !date.startsWith(anchor.slice(0, 7)), 'is-today': date === today, 'is-selected': date === selectedDate }" :data-date="date">
|
||||
<button class="day-heading" type="button" :disabled="loading || failed || !types.length" :aria-current="date === today ? 'date' : undefined" :aria-pressed="date === selectedDate" :aria-label="`${date} 工作明细`" @click="emit('day', date)"><span class="date-number">{{ Number(date.slice(8)) }}</span><small v-if="date === today" class="today-mark" title="浏览器当前日期">今天</small><span v-if="loading || failed || !types.length" class="day-total">—</span><span v-else-if="(aggregates.get(date)?.total ?? 0) > 0" class="day-total">{{ aggregates.get(date)?.total }} 项</span></button>
|
||||
<div class="type-counts"><template v-if="!loading && !failed && types.length"><button v-for="type in workTypes.filter(type => types.includes(type) && (aggregates.get(date)?.typeCounts[type] ?? 0) > 0)" :key="type" type="button" :data-type="type" :aria-pressed="date === selectedDate && type === selectedType" :aria-label="`${date} ${typeLabels[type]} ${aggregates.get(date)?.typeCounts[type]} 项`" @click="emit('day', date, type)"><span>{{ typeLabels[type] }}</span><b>{{ aggregates.get(date)?.typeCounts[type] }}</b></button></template></div>
|
||||
<button v-if="reference && referenceReady && arrivalCount(date)" type="button" class="arrival-reference" @click="emit('arrivals', date)">到港参考 {{ arrivalCount(date) }} 柜</button>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="muted">点击日期或类型查看明细;到港参考不计工作量。已执行按实际日期展示。</footer>
|
||||
<footer class="muted">日历只展示有作业日期的事项,未定日期请从上方待办查看。已执行按实际日期展示;到港参考不计工作量。</footer>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.work-calendar { min-width: 0; border: 1px solid var(--cc-border); background: var(--admin-surface); }
|
||||
.calendar-toolbar, .period-controls, .work-layers, .calendar-counts { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.calendar-toolbar { justify-content: space-between; padding: var(--admin-space-5); }
|
||||
.calendar-toolbar label { display: flex; align-items: center; gap: var(--admin-space-3); }
|
||||
.global-work { border-bottom: 1px solid var(--cc-border); }
|
||||
.calendar-toolbar { justify-content: flex-start; column-gap: var(--admin-space-6); padding: var(--admin-space-2) var(--admin-space-5); }
|
||||
.flow-controls { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-2); }
|
||||
.period-controls strong { min-width: 100px; text-align: center; }
|
||||
.work-layers, .calendar-counts { padding: var(--admin-space-3) var(--admin-space-5); border-top: 1px solid var(--cc-border); }
|
||||
.work-layers, .calendar-counts { padding: var(--admin-space-2) var(--admin-space-5); border-top: 1px solid var(--cc-border); }
|
||||
.work-layers label { display: inline-flex; align-items: center; gap: var(--admin-space-2); cursor: pointer; }
|
||||
.calendar-counts { min-height: 32px; font-size: var(--admin-font-caption); }
|
||||
.calendar-counts { min-height: 24px; font-size: var(--admin-font-caption); }
|
||||
.weekdays, .calendar-days { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||
.weekdays { text-align: center; padding-block: var(--admin-space-3); background: var(--el-fill-color-light); }
|
||||
.work-day { height: 140px; min-height: 140px; max-height: 140px; box-sizing: border-box; overflow: hidden; border-top: 1px solid var(--cc-border); border-right: 1px solid var(--cc-border); padding: var(--admin-space-2); }
|
||||
.work-day { min-height: 112px; box-sizing: border-box; border-top: 1px solid var(--cc-border); border-right: 1px solid var(--cc-border); padding: var(--admin-space-1); }
|
||||
.week-view .work-day { min-height: 240px; }
|
||||
.work-day:nth-child(7n) { border-right: 0; }
|
||||
.outside { background: var(--el-fill-color-lighter); }
|
||||
.day-heading { display: flex; justify-content: space-between; width: 100%; border: 0; padding: 0 var(--admin-space-2); min-height: 24px; background: transparent; }
|
||||
.type-counts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: 24px; gap: 2px; min-height: 76px; }
|
||||
.type-counts button { display: flex; align-items: center; justify-content: space-between; gap: 2px; border: 1px solid currentColor; padding: 0 3px; font-size: var(--admin-font-caption); background: transparent; min-width: 0; min-height: 24px; }
|
||||
.work-calendar .day-heading { display: flex; align-items: center; gap: var(--admin-space-1); width: 100%; border: 0; padding: 0; min-height: 24px; background: transparent; margin-bottom: var(--admin-space-1); }
|
||||
.date-number { display: inline-flex; align-items: center; justify-content: center; min-width: 24px; height: 24px; border-radius: 50%; }
|
||||
.is-today .date-number { color: var(--el-color-white); background: var(--el-color-primary); font-weight: 600; }
|
||||
.today-mark { color: var(--el-color-primary); font-size: var(--admin-font-caption); }
|
||||
.day-total { margin-left: auto; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.work-day.is-selected { box-shadow: inset 0 0 0 2px var(--el-color-primary); background: var(--el-color-primary-light-9); }
|
||||
.outside .day-heading { color: var(--el-text-color-secondary); }
|
||||
.type-counts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: minmax(24px, auto); gap: 2px; }
|
||||
.work-calendar .type-counts button { display: flex; align-items: center; justify-content: space-between; gap: 2px; border: 1px solid currentColor; padding: 0 3px; font-size: var(--admin-font-caption); background: transparent; min-width: 0; min-height: 24px; white-space: nowrap; }
|
||||
.type-counts button[aria-pressed=true] { background: var(--admin-surface); box-shadow: inset 0 0 0 1px currentColor; }
|
||||
.type-counts b { font-variant-numeric: tabular-nums; }
|
||||
[data-type=PICKUP] { color: var(--el-color-primary); } [data-type=WAREHOUSE_RECEIPT] { color: #8356ad; } [data-type=DELIVERY] { color: #27815c; } [data-type=TRANSPORT] { color: #b26a12; } [data-type=EMPTY_RETURN] { color: #a64f69; }
|
||||
.arrival-reference { border: 0; background: transparent; padding: 0; min-height: 18px; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
||||
.work-calendar .arrival-reference { border: 0; background: transparent; padding: 0; min-height: 24px; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
||||
footer { padding: var(--admin-space-3) var(--admin-space-5); }
|
||||
button, select { font: inherit; cursor: pointer; border-radius: var(--admin-radius); min-height: var(--admin-control-height); color: inherit; }
|
||||
button:disabled { cursor: default; opacity: .6; } button:focus-visible, select:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 1px; }
|
||||
button { font: inherit; cursor: pointer; border-radius: var(--admin-radius); min-height: var(--admin-control-height); color: inherit; border: 1px solid var(--cc-border); background: var(--admin-surface); padding: 0 var(--admin-space-3); }
|
||||
.view-switch { display: inline-flex; }
|
||||
.view-switch button { border-radius: 0; min-width: 36px; }
|
||||
.view-switch button:first-child { border-radius: var(--admin-radius) 0 0 var(--admin-radius); }
|
||||
.view-switch button:last-child { border-radius: 0 var(--admin-radius) var(--admin-radius) 0; margin-left: -1px; }
|
||||
.view-switch button[aria-pressed=true], .flow-controls button[aria-pressed=true] { background: var(--el-color-primary-light-9); border-color: var(--el-color-primary); color: var(--el-color-primary); position: relative; }
|
||||
button:hover:not(:disabled) { border-color: var(--el-color-primary); }
|
||||
button:disabled { cursor: default; opacity: .6; } button:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 1px; }
|
||||
.muted { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
</style>
|
||||
|
||||
@@ -171,6 +171,27 @@ const cssRoot = (css: string) => {
|
||||
}
|
||||
|
||||
describe('approved Admin density ownership', () => {
|
||||
it('keeps appointment details over the calendar without clipping days or scrolling away panel controls', () => {
|
||||
const declarations = (file: string, 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 }) })
|
||||
return values
|
||||
}
|
||||
const panel = declarations('AppointmentCalendarView.vue', '.work-panel')
|
||||
expect(panel.position).toBe('absolute')
|
||||
expect(panel.height).toContain('100dvh')
|
||||
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('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(parseInt(day['min-height']!)).toBeLessThan(140)
|
||||
expect(day['max-height']).toBeUndefined()
|
||||
expect(day.overflow).not.toBe('hidden')
|
||||
})
|
||||
it('shares list status and category tab styles across registered list consumers', () => {
|
||||
for (const file of [
|
||||
'unified-orders/pages/UnifiedOrdersView.vue', 'overseas-warehouse/pages/LastMileOrdersView.vue',
|
||||
|
||||
Reference in New Issue
Block a user