119 lines
13 KiB
Vue
119 lines
13 KiB
Vue
<script setup lang="ts">
|
||
import '@/styles/adminDensity.css'
|
||
import { computed, ref } from 'vue'
|
||
import { Plus } from '@lucide/vue'
|
||
import { calendarDates, calendarRange, dateKey, moveCalendar, type CalendarMode } from './calendar'
|
||
import { scheduleLabels, scheduleTypes, type ScheduleEvent, type ScheduleType } from './schedule'
|
||
|
||
const props = withDefaults(defineProps<{ events: ScheduleEvent[]; loading?: boolean; incomplete?: boolean; initialDate?: string; initialMode?: CalendarMode; canCreate?: boolean }>(),
|
||
{ loading: false, incomplete: false, initialDate: '', initialMode: 'month', canCreate: false })
|
||
const emit = defineEmits<{ range: [range: { from: string; to: string }]; select: [date: string, mode: CalendarMode]; open: [event: ScheduleEvent]; edit: [event: ScheduleEvent]; create: [] }>()
|
||
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, ScheduleEvent[]>()
|
||
for (const event of visibleEvents.value) {
|
||
const date = event.date || 'undated'
|
||
const rows = result.get(date) || []; rows.push(event); result.set(date, rows)
|
||
}
|
||
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(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; 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('select', selectedDate.value, next); emit('range', calendarRange(selectedDate.value, next))
|
||
}
|
||
</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" @click="emit('create')"><Plus :size="16" aria-hidden="true" />新增预约</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-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 ['周一', '周二', '周三', '周四', '周五', '周六', '周日']" :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>{{ 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 ? '已加载数据中没有安排,请先处理上方数据提示。' : 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">{{ 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"><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>
|
||
.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: 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, .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; 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: 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>
|