85 lines
11 KiB
Vue
85 lines
11 KiB
Vue
<script setup lang="ts">
|
||
import { computed } from 'vue'
|
||
import { calendarDates, dateKey, type CalendarMode } from './calendar'
|
||
import { workTypes, type WorkCalendar, type WorkFlow, type WorkType } from './workApi'
|
||
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; 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" :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">
|
||
<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.from }} ~ {{ calendar.to }} · {{ calendar.total }} 项工作</strong><span :class="{ 'is-zero': calendar.counts.pending === 0 }">待安排 {{ calendar.counts.pending }}</span><span :class="{ 'is-zero': calendar.counts.confirmed === 0 }">已落实 {{ calendar.counts.confirmed }}</span><span :class="{ 'is-zero': calendar.counts.noBookingRequired === 0 }">无需预约 {{ calendar.counts.noBookingRequired }}</span><span :class="{ 'is-zero': calendar.counts.review === 0 }">需复核 {{ calendar.counts.review }}</span><span :class="{ 'is-zero': calendar.counts.completed === 0 }">已完成 {{ 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)), '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>
|
||
</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); }
|
||
.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-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: 24px; font-size: var(--admin-font-caption); }
|
||
.calendar-counts .is-zero { color: var(--el-text-color-secondary); }
|
||
.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); }
|
||
.weekdays span:nth-last-child(-n+2) { color: var(--el-text-color-secondary); }
|
||
.work-day { height: 146px; min-height: 146px; max-height: 146px; 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 { height: 270px; min-height: 270px; max-height: 270px; }
|
||
.work-day:nth-child(7n) { border-right: 0; }
|
||
.outside { background: var(--el-fill-color-lighter); }
|
||
.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%; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||
.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: var(--admin-space-1); }
|
||
.work-calendar .type-counts button { display: flex; align-items: center; justify-content: space-between; gap: 2px; border: 1px solid color-mix(in srgb, currentColor 20%, transparent); padding: 0 var(--admin-space-1); font-size: var(--admin-font-caption); background: color-mix(in srgb, currentColor 7%, var(--admin-surface)); min-width: 0; min-height: 24px; white-space: nowrap; }
|
||
.work-calendar .type-counts button:hover:not(:disabled) { border-color: currentColor; background: color-mix(in srgb, currentColor 12%, var(--admin-surface)); }
|
||
.work-calendar .type-counts button[aria-pressed=true] { border-color: currentColor; background: color-mix(in srgb, currentColor 14%, 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; }
|
||
.work-calendar .arrival-reference { display: block; border: 0; background: transparent; padding: 0 var(--admin-space-1); margin-top: var(--admin-space-1); min-height: 24px; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
||
.work-calendar .arrival-reference:hover { color: var(--el-text-color-regular); background: var(--el-fill-color-light); }
|
||
footer { padding: var(--admin-space-3) var(--admin-space-5); }
|
||
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>
|