Files
admin-web/src/modules/appointment-calendar/AppointmentCalendarPanel.vue
T

165 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, ref } from 'vue'
import { addDays, calendarDates, calendarRange, dateKey, moveCalendar, type CalendarMode } from './calendar'
export interface CalendarEvent {
key: string
date: string
time: string
type: 'arrival' | 'delivery'
title: string
subtitle?: string
fields: Array<{ label: string; value?: string | null }>
canEdit?: boolean
canDelete?: boolean
}
const props = withDefaults(defineProps<{
events: CalendarEvent[]
loading?: boolean
canCreate?: boolean
initialDate?: string
}>(), { loading: false, canCreate: false, initialDate: '' })
const emit = defineEmits<{
range: [range: { from: string; to: string }]
create: [date: string]
edit: [event: CalendarEvent]
delete: [event: CalendarEvent]
}>()
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 props.events) {
const group = result.get(event.date) || []
group.push(event)
result.set(event.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 weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
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>
<span class="legend arrival">船舶到港</span><span class="legend delivery">预约送仓</span>
</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="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},共 ${(grouped.get(date) || []).length} 条安排`" :aria-label="`${date},${(grouped.get(date) || []).length} 项安排`" :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} · 当天共 ${(grouped.get(date) || []).length} 条安排`">
{{ 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>当天共有 <b>{{ selectedEvents.length }}</b> 项安排</p></header>
<div class="detail-events" aria-live="polite">
<p v-if="!selectedEvents.length && !loading" class="empty-day">当天暂无到港或送仓预约</p>
<article v-for="event in selectedEvents" :key="event.key" class="detail-event">
<div class="event-heading"><span class="event-type" :class="event.type">{{ event.type === 'arrival' ? '船舶到港' : '预约送仓' }}</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"><dt>{{ field.label }}</dt><dd>{{ field.value || '—' }}</dd></div></dl>
<footer v-if="event.canEdit || event.canDelete" class="density-actions">
<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; grid-template-columns: minmax(0, 2.5fr) minmax(320px, 1fr); gap: 16px; 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: 12px; padding: 16px; 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: 16px; }
.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; }
.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: 12px 14px; font-size: 13px; }
.calendar-grid { gap: 7px; padding: 12px; background: #fbfcff; }
.calendar-day { display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; height: 128px; min-width: 0; padding: 8px; 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: 3px; margin-top: 6px; }
.event-chip { display: block; padding: 3px 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; }
.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: 18px; border-bottom: 1px solid #edf0f6; }
.calendar-detail > header > strong { font-size: 17px; }
.calendar-detail header p { margin: 10px 0 0; font-size: 13px; color: #8491a5; }
.calendar-detail header b { color: #617ce9; font-size: 17px; }
.detail-events { padding: 14px; max-height: 650px; overflow-y: auto; }
.detail-event + .detail-event { border-top: 1px solid #e4e9f2; margin-top: 16px; padding-top: 16px; }
.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: 15px; margin: 14px 0 8px; 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: 12px; margin: 16px 0; }
.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>