fix: rewrite matrix view as weekly timetable grid (days × slots)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-02 09:14:09 +08:00
parent 4843e3839e
commit 0fa623b73c
+197 -171
View File
@@ -1,41 +1,49 @@
<template> <template>
<div class="matrix-wrapper"> <div class="matrix-wrapper">
<!-- 双行表头日期 + 二级时段 --> <div class="matrix-grid">
<div class="matrix-header"> <!-- Corner -->
<div class="header-col" v-for="col in columns" :key="col.key"> <div class="grid-cell corner-cell"></div>
<div class="header-date">{{ col.dateLabel }}</div>
<div class="header-time">{{ col.slotLabel }}</div>
</div>
</div>
<!-- 表体课块平铺 --> <!-- Header: time slots -->
<div class="matrix-body" ref="bodyRef"> <div
<div class="body-col" v-for="col in columns" :key="col.key"> v-for="slot in timeSlots"
<div :key="slot.key"
v-for="lane in col.lanes" class="grid-cell header-cell"
class="event-lane" >
> <div class="header-label">{{ slot.label }}</div>
<div <div class="header-time">{{ slot.time }}</div>
v-for="ev in lane"
:key="ev.id"
class="event-block"
:class="{ 'event-block--ghost': ev.isRepeatGhost }"
:style="eventStyle(ev)"
@click.stop="onEventClick(ev)"
>
<div class="ev-title">{{ ev.clazzName }}</div>
<div class="ev-time">{{ fmtTime(ev.startAt) }}-{{ fmtTime(ev.endAt) }}</div>
<div class="ev-teacher" v-if="ev.teacherName">{{ ev.teacherName }}</div>
<div class="ev-students" v-if="ev.studentNames.length">{{ ev.studentNames.join('') }}</div>
</div>
</div>
<!-- slot 占位点击可创建 -->
<div
v-if="!col.lanes.length"
class="slot-empty"
@click="onCellClick(col.dateKey, col.slotKey)"
>+</div>
</div> </div>
<!-- Day rows: 周一 ~ 周日 -->
<template v-for="day in weekDays" :key="day.dateKey">
<div
class="grid-cell day-cell"
:class="{ 'day-cell--today': day.isToday }"
>
<div class="day-name">{{ day.name }}</div>
<div class="day-date">{{ day.date }}</div>
</div>
<div
v-for="slot in timeSlots"
:key="`${day.dateKey}_${slot.key}`"
class="grid-cell event-cell"
>
<template v-if="eventMap[day.dateKey]?.[slot.key]?.length">
<div
v-for="ev in eventMap[day.dateKey][slot.key]"
:key="ev.id"
class="event-block"
:style="eventStyle(ev)"
@click.stop="onEventClick(ev)"
>
<div class="ev-title">{{ ev.clazzName }}</div>
<div class="ev-teacher" v-if="ev.teacherName">{{ ev.teacherName }}</div>
<div class="ev-students" v-if="ev.studentNames.length">{{ ev.studentNames.join('') }}</div>
</div>
</template>
<div v-else class="cell-empty" @click="onCellClick(day.dateKey, slot.key)">+</div>
</div>
</template>
</div> </div>
</div> </div>
</template> </template>
@@ -43,36 +51,34 @@
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, PropType } from 'vue'; import { computed, defineComponent, PropType } from 'vue';
import { NormalizedClazzEvent } from '../useClazzViewModel'; import { NormalizedClazzEvent } from '../useClazzViewModel';
import { SLOT_LABELS } from '../constants'; import { DEFAULT_TIME_SLOTS, SLOT_LABELS } from '../constants';
import { DateFormatter, formatDate } from '~/utils'; import { DateFormatter, formatDate } from '~/utils';
interface MatrixCol { interface TimeSlot {
key: string; key: string;
label: string;
time: string;
}
interface WeekDay {
dateKey: string; dateKey: string;
slotKey: string; name: string;
dateLabel: string; date: string;
slotLabel: string; isToday: boolean;
lanes: NormalizedClazzEvent[][];
} }
const WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; const WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
function packLanes(events: NormalizedClazzEvent[]): NormalizedClazzEvent[][] { const timeSlotsData: TimeSlot[] = DEFAULT_TIME_SLOTS.map((t) => ({
const sorted = [...events].sort((a, b) => a.startAt.getTime() - b.startAt.getTime()); key: t,
const lanes: NormalizedClazzEvent[][] = []; label: SLOT_LABELS[t] || t,
for (const ev of sorted) { time: t,
let placed = false; }));
for (const lane of lanes) {
const last = lane[lane.length - 1]; function isSameDay(a: Date, b: Date): boolean {
if (last.endAt.getTime() <= ev.startAt.getTime()) { return a.getFullYear() === b.getFullYear()
lane.push(ev); && a.getMonth() === b.getMonth()
placed = true; && a.getDate() === b.getDate();
break;
}
}
if (!placed) lanes.push([ev]);
}
return lanes;
} }
export default defineComponent({ export default defineComponent({
@@ -84,8 +90,26 @@ export default defineComponent({
}, },
emits: ['cell-click', 'event-click'], emits: ['cell-click', 'event-click'],
setup(props, { emit }) { setup(props, { emit }) {
/** 按(dateKey, timeSlotKey)聚合事件 */ const timeSlots = timeSlotsData;
const eventsBySlot = computed(() => { const today = computed(() => new Date());
const weekDays = computed<WeekDay[]>(() => {
const days: WeekDay[] = [];
const start = new Date(props.weekStart);
for (let i = 0; i < 7; i++) {
const d = new Date(start);
d.setDate(d.getDate() + i);
days.push({
dateKey: formatDate(d, DateFormatter.Date),
name: WEEKDAYS[d.getDay()],
date: formatDate(d, 'MM-DD'),
isToday: isSameDay(d, today.value),
});
}
return days;
});
const eventMap = computed(() => {
const map: Record<string, Record<string, NormalizedClazzEvent[]>> = {}; const map: Record<string, Record<string, NormalizedClazzEvent[]>> = {};
for (const ev of props.events) { for (const ev of props.events) {
if (!ev.visible) continue; if (!ev.visible) continue;
@@ -96,37 +120,6 @@ export default defineComponent({
return map; return map;
}); });
/** 生成列:每个 date×slot 组合一列,仅含课时段 */
const columns = computed<MatrixCol[]>(() => {
const cols: MatrixCol[] = [];
const start = new Date(props.weekStart);
const end = new Date(props.weekEnd);
const cursor = new Date(start);
while (cursor <= end) {
const dk = formatDate(cursor, DateFormatter.Date);
const dateLabel = `${WEEKDAYS[cursor.getDay()]} ${formatDate(cursor, 'MM-DD')}`;
const slots = eventsBySlot.value[dk];
if (slots) {
for (const [slotKey, evs] of Object.entries(slots)) {
cols.push({
key: `${dk}_${slotKey}`,
dateKey: dk,
slotKey,
dateLabel,
slotLabel: SLOT_LABELS[slotKey] || slotKey,
lanes: packLanes(evs),
});
}
}
cursor.setDate(cursor.getDate() + 1);
}
return cols;
});
function fmtTime(d: Date): string {
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
function eventStyle(ev: NormalizedClazzEvent) { function eventStyle(ev: NormalizedClazzEvent) {
return { return {
backgroundColor: ev.color || 'rgba(55, 136, 216, 0.12)', backgroundColor: ev.color || 'rgba(55, 136, 216, 0.12)',
@@ -142,7 +135,7 @@ export default defineComponent({
emit('event-click', ev); emit('event-click', ev);
} }
return { columns, fmtTime, eventStyle, onCellClick, onEventClick }; return { timeSlots, weekDays, eventMap, eventStyle, onCellClick, onEventClick };
}, },
}); });
</script> </script>
@@ -151,102 +144,135 @@ export default defineComponent({
.matrix-wrapper { .matrix-wrapper {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: flex;
flex-direction: column;
font-size: 0.24rem;
overflow: auto;
} }
/* ═══ 双行表头 ═══ */ .matrix-grid {
.matrix-header { display: grid;
display: flex; grid-template-columns: 1.2rem repeat(4, 1fr);
flex-shrink: 0; grid-template-rows: auto repeat(7, 1fr);
position: sticky; height: 100%;
top: 0; min-height: 100%;
z-index: 2; }
.grid-cell {
min-width: 0;
min-height: 0;
overflow: hidden;
}
/* ─── Corner ─── */
.corner-cell {
background: #f7f8fa; background: #f7f8fa;
border-bottom: 1px solid #e8e8e8; border-bottom: 1px solid #e8e8e8;
border-right: 1px solid #e8e8e8;
}
.header-col { /* ─── Header row (time slots) ─── */
flex-shrink: 0; .header-cell {
width: 2.4rem; background: #f7f8fa;
text-align: center; text-align: center;
border-left: 1px solid #f0f0f0; padding: 0.08rem 0.04rem;
padding: 0.12rem 0.06rem; border-bottom: 1px solid #e8e8e8;
border-left: 1px solid #f0f0f0;
&:first-child { border-left: none; } .header-label {
font-weight: 600;
.header-date { font-size: 0.22rem;
font-weight: 600; line-height: 1.4;
font-size: 0.26rem; }
line-height: 1.5; .header-time {
} font-size: 0.18rem;
.header-time { color: #888;
font-size: 0.22rem; line-height: 1.3;
color: #888;
line-height: 1.4;
}
} }
} }
/* ═══ 表体 ═══ */ /* ─── Day sidebar ─── */
.matrix-body { .day-cell {
display: flex; display: flex;
flex: 1; flex-direction: column;
overflow: auto; align-items: center;
justify-content: center;
padding: 0.04rem;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
border-right: 1px solid #f0f0f0;
.body-col { .day-name {
flex-shrink: 0; font-weight: 600;
width: 2.4rem;
padding: 0.08rem;
border-left: 1px solid #f5f5f5;
display: flex;
flex-direction: column;
gap: 0.06rem;
&:first-child { border-left: none; }
}
.event-lane {
display: flex;
gap: 0.06rem;
}
.event-block {
flex: 1;
padding: 0.06rem 0.1rem;
border-radius: 0.06rem;
cursor: pointer;
overflow: hidden;
font-size: 0.22rem; font-size: 0.22rem;
transition: box-shadow 0.15s; line-height: 1.4;
}
&:hover { box-shadow: 0 0.04rem 0.12rem rgba(0,0,0,0.15); } .day-date {
font-size: 0.18rem;
&--ghost { opacity: 0.55; border-style: dashed; } color: #888;
line-height: 1.3;
.ev-title { font-weight: 600; font-size: 0.24rem; }
.ev-time { font-size: 0.2rem; color: #666; }
.ev-teacher { font-size: 0.2rem; color: #555; margin-top: 0.02rem; }
.ev-students { font-size: 0.2rem; color: #777; }
} }
.slot-empty { &--today {
flex: 1; background: #e6f7ff;
display: flex; .day-name { color: #1890ff; }
align-items: center; }
justify-content: center; }
min-height: 1rem;
color: #ccc;
font-size: 0.3rem;
cursor: pointer;
border: 1px dashed #eee;
border-radius: 0.06rem;
&:hover { /* ─── Event cells ─── */
background: #f0f5ff; .event-cell {
color: #3788d8; padding: 0.03rem;
} border-bottom: 1px solid #f5f5f5;
border-left: 1px solid #f5f5f5;
}
.event-block {
padding: 0.04rem 0.06rem;
border-radius: 0.04rem;
cursor: pointer;
overflow: hidden;
margin-bottom: 0.02rem;
&:last-child { margin-bottom: 0; }
.ev-title {
font-weight: 600;
font-size: 0.2rem;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ev-teacher {
font-size: 0.16rem;
color: #555;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ev-students {
font-size: 0.16rem;
color: #777;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.cell-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 0.5rem;
color: #ccc;
font-size: 0.28rem;
cursor: pointer;
border-radius: 0.04rem;
&:active {
background: #f0f5ff;
color: #3788d8;
} }
} }
</style> </style>