194 lines
14 KiB
Vue
194 lines
14 KiB
Vue
<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'
|
||
|
||
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
|
||
canDelete?: boolean
|
||
sourcePath?: string
|
||
}
|
||
|
||
const props = withDefaults(defineProps<{
|
||
events: CalendarEvent[]
|
||
loading?: boolean
|
||
incomplete?: boolean
|
||
canCreate?: boolean
|
||
initialDate?: string
|
||
}>(), { 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]
|
||
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 dates = computed(() => calendarDates(selectedDate.value, mode.value))
|
||
const grouped = computed(() => {
|
||
const result = new Map<string, CalendarEvent[]>()
|
||
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)
|
||
}
|
||
}
|
||
for (const group of result.values()) group.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)}`)
|
||
function select(date: string) {
|
||
const before = calendarRange(selectedDate.value, mode.value)
|
||
selectedDate.value = date
|
||
const after = calendarRange(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))
|
||
}
|
||
defineExpose({ selectedDate, mode })
|
||
</script>
|
||
|
||
<template>
|
||
<section class="appointment-calendar admin-density" :aria-busy="loading">
|
||
<div class="calendar-main">
|
||
<header class="calendar-toolbar">
|
||
<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>
|
||
</div>
|
||
<div class="calendar-controls">
|
||
<el-button v-if="canCreate" type="primary" :disabled="loading" @click="emit('create', selectedDate)">+ 新增预约</el-button>
|
||
<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>
|
||
</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>
|
||
<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>
|
||
</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>
|
||
<div class="detail-events" aria-live="polite">
|
||
<p v-if="!selectedEvents.length && !loading" class="empty-day">{{ incomplete ? '当前范围内暂无预约;请按上方提示核对缺失资料或失败来源' : '当天暂无预约' }}</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>
|
||
<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.canEdit || event.canDelete || event.sourcePath" class="density-actions">
|
||
<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>
|
||
</article>
|
||
</div>
|
||
</aside>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.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; }
|
||
.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-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; }
|
||
.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); }
|
||
.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; }
|
||
.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; }
|
||
</style>
|