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>
<div class="matrix-wrapper">
<!-- 双行表头日期 + 二级时段 -->
<div class="matrix-header">
<div class="header-col" v-for="col in columns" :key="col.key">
<div class="header-date">{{ col.dateLabel }}</div>
<div class="header-time">{{ col.slotLabel }}</div>
</div>
</div>
<div class="matrix-grid">
<!-- Corner -->
<div class="grid-cell corner-cell"></div>
<!-- 表体课块平铺 -->
<div class="matrix-body" ref="bodyRef">
<div class="body-col" v-for="col in columns" :key="col.key">
<div
v-for="lane in col.lanes"
class="event-lane"
>
<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>
<!-- Header: time slots -->
<div
v-for="slot in timeSlots"
:key="slot.key"
class="grid-cell header-cell"
>
<div class="header-label">{{ slot.label }}</div>
<div class="header-time">{{ slot.time }}</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>
</template>
@@ -43,36 +51,34 @@
<script lang="ts">
import { computed, defineComponent, PropType } from 'vue';
import { NormalizedClazzEvent } from '../useClazzViewModel';
import { SLOT_LABELS } from '../constants';
import { DEFAULT_TIME_SLOTS, SLOT_LABELS } from '../constants';
import { DateFormatter, formatDate } from '~/utils';
interface MatrixCol {
interface TimeSlot {
key: string;
label: string;
time: string;
}
interface WeekDay {
dateKey: string;
slotKey: string;
dateLabel: string;
slotLabel: string;
lanes: NormalizedClazzEvent[][];
name: string;
date: string;
isToday: boolean;
}
const WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
function packLanes(events: NormalizedClazzEvent[]): NormalizedClazzEvent[][] {
const sorted = [...events].sort((a, b) => a.startAt.getTime() - b.startAt.getTime());
const lanes: NormalizedClazzEvent[][] = [];
for (const ev of sorted) {
let placed = false;
for (const lane of lanes) {
const last = lane[lane.length - 1];
if (last.endAt.getTime() <= ev.startAt.getTime()) {
lane.push(ev);
placed = true;
break;
}
}
if (!placed) lanes.push([ev]);
}
return lanes;
const timeSlotsData: TimeSlot[] = DEFAULT_TIME_SLOTS.map((t) => ({
key: t,
label: SLOT_LABELS[t] || t,
time: t,
}));
function isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth()
&& a.getDate() === b.getDate();
}
export default defineComponent({
@@ -84,8 +90,26 @@ export default defineComponent({
},
emits: ['cell-click', 'event-click'],
setup(props, { emit }) {
/** 按(dateKey, timeSlotKey)聚合事件 */
const eventsBySlot = computed(() => {
const timeSlots = timeSlotsData;
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[]>> = {};
for (const ev of props.events) {
if (!ev.visible) continue;
@@ -96,37 +120,6 @@ export default defineComponent({
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) {
return {
backgroundColor: ev.color || 'rgba(55, 136, 216, 0.12)',
@@ -142,7 +135,7 @@ export default defineComponent({
emit('event-click', ev);
}
return { columns, fmtTime, eventStyle, onCellClick, onEventClick };
return { timeSlots, weekDays, eventMap, eventStyle, onCellClick, onEventClick };
},
});
</script>
@@ -151,102 +144,135 @@ export default defineComponent({
.matrix-wrapper {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
font-size: 0.24rem;
overflow: auto;
}
/* ═══ 双行表头 ═══ */
.matrix-header {
display: flex;
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 2;
.matrix-grid {
display: grid;
grid-template-columns: 1.2rem repeat(4, 1fr);
grid-template-rows: auto repeat(7, 1fr);
height: 100%;
min-height: 100%;
}
.grid-cell {
min-width: 0;
min-height: 0;
overflow: hidden;
}
/* ─── Corner ─── */
.corner-cell {
background: #f7f8fa;
border-bottom: 1px solid #e8e8e8;
border-right: 1px solid #e8e8e8;
}
.header-col {
flex-shrink: 0;
width: 2.4rem;
text-align: center;
border-left: 1px solid #f0f0f0;
padding: 0.12rem 0.06rem;
/* ─── Header row (time slots) ─── */
.header-cell {
background: #f7f8fa;
text-align: center;
padding: 0.08rem 0.04rem;
border-bottom: 1px solid #e8e8e8;
border-left: 1px solid #f0f0f0;
&:first-child { border-left: none; }
.header-date {
font-weight: 600;
font-size: 0.26rem;
line-height: 1.5;
}
.header-time {
font-size: 0.22rem;
color: #888;
line-height: 1.4;
}
.header-label {
font-weight: 600;
font-size: 0.22rem;
line-height: 1.4;
}
.header-time {
font-size: 0.18rem;
color: #888;
line-height: 1.3;
}
}
/* ═══ 表体 ═══ */
.matrix-body {
/* ─── Day sidebar ─── */
.day-cell {
display: flex;
flex: 1;
overflow: auto;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.04rem;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
border-right: 1px solid #f0f0f0;
.body-col {
flex-shrink: 0;
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;
.day-name {
font-weight: 600;
font-size: 0.22rem;
transition: box-shadow 0.15s;
&:hover { box-shadow: 0 0.04rem 0.12rem rgba(0,0,0,0.15); }
&--ghost { opacity: 0.55; border-style: dashed; }
.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; }
line-height: 1.4;
}
.day-date {
font-size: 0.18rem;
color: #888;
line-height: 1.3;
}
.slot-empty {
flex: 1;
display: flex;
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;
&--today {
background: #e6f7ff;
.day-name { color: #1890ff; }
}
}
&:hover {
background: #f0f5ff;
color: #3788d8;
}
/* ─── Event cells ─── */
.event-cell {
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>