fix: restructure matrix view layout - X=date+time header, Y=event blocks

- Remove Y-axis time slot rows (was wrong)
- X-axis: hierarchical header (weekday + date | time slot name)
- Body: lane-packed event blocks in each date×slot column
- Show teacher name and student names in each block
- Empty slots show clickable '+' placeholder

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-02 08:58:53 +08:00
parent 8a33f35c87
commit 4843e3839e
+125 -208
View File
@@ -1,86 +1,64 @@
<template> <template>
<div class="matrix-wrapper" ref="wrapperRef"> <div class="matrix-wrapper">
<!-- 表头日期 + 无课日合并列 --> <!-- 双行表头日期 + 二级时段 -->
<div class="matrix-header"> <div class="matrix-header">
<div class="header-corner">时段</div> <div class="header-col" v-for="col in columns" :key="col.key">
<div
v-for="col in columns"
:key="col.dateKey"
class="header-cell"
:style="{ width: colWidth }"
>
<div class="header-date">{{ col.dateLabel }}</div> <div class="header-date">{{ col.dateLabel }}</div>
<div class="header-weekday">{{ col.weekdayLabel }}</div> <div class="header-time">{{ col.slotLabel }}</div>
</div> </div>
</div> </div>
<!-- 表体每个时段一行 --> <!-- 表体课块平铺 -->
<div class="matrix-body"> <div class="matrix-body" ref="bodyRef">
<template v-for="slotKey in timeSlots" :key="slotKey"> <div class="body-col" v-for="col in columns" :key="col.key">
<!-- 时段标签行 --> <div
<div class="slot-row"> v-for="lane in col.lanes"
<div class="slot-label">{{ slotLabels[slotKey] || slotKey }}</div> class="event-lane"
>
<div <div
v-for="col in columns" v-for="ev in lane"
:key="col.dateKey" :key="ev.id"
class="slot-cell" class="event-block"
:style="{ width: colWidth }" :class="{ 'event-block--ghost': ev.isRepeatGhost }"
:class="{ :style="eventStyle(ev)"
'slot-cell--today': col.isToday, @click.stop="onEventClick(ev)"
'slot-cell--past': col.isPast,
'slot-cell--empty': !eventsBySlot[col.dateKey]?.[slotKey]?.length,
}"
@click="onCellClick(col.dateKey, slotKey)"
> >
<!-- Lane-packed events --> <div class="ev-title">{{ ev.clazzName }}</div>
<template v-if="lanesBySlot[col.dateKey]?.[slotKey]"> <div class="ev-time">{{ fmtTime(ev.startAt) }}-{{ fmtTime(ev.endAt) }}</div>
<div <div class="ev-teacher" v-if="ev.teacherName">{{ ev.teacherName }}</div>
v-for="(lane, laneIdx) in lanesBySlot[col.dateKey][slotKey]" <div class="ev-students" v-if="ev.studentNames.length">{{ ev.studentNames.join('') }}</div>
:key="laneIdx"
class="event-lane"
:style="{ height: laneHeight }"
>
<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="event-title">{{ ev.clazzName }}</div>
<div class="event-meta">{{ fmtTime(ev.startAt) }}-{{ fmtTime(ev.endAt) }}</div>
<div class="event-meta" v-if="ev.teacherName">{{ ev.teacherName }}</div>
<div class="event-meta event-meta--students" v-if="ev.studentNames.length">{{ ev.studentNames.join('') }}</div>
</div>
</div>
</template>
</div> </div>
</div> </div>
</template> <!-- slot 占位点击可创建 -->
<div
v-if="!col.lanes.length"
class="slot-empty"
@click="onCellClick(col.dateKey, col.slotKey)"
>+</div>
</div>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, PropType, ref } from 'vue'; import { computed, defineComponent, PropType } from 'vue';
import { NormalizedClazzEvent } from '../useClazzViewModel'; import { NormalizedClazzEvent } from '../useClazzViewModel';
import { DEFAULT_TIME_SLOTS, SLOT_LABELS } from '../constants'; import { SLOT_LABELS } from '../constants';
import { DateFormatter, formatDate } from '~/utils'; import { DateFormatter, formatDate } from '~/utils';
export interface MatrixColumn { interface MatrixCol {
key: string;
dateKey: string; dateKey: string;
dateLabel: string; // MM-DD slotKey: string;
weekdayLabel: string; // 周X dateLabel: string;
isToday: boolean; slotLabel: string;
isPast: boolean; lanes: NormalizedClazzEvent[][];
} }
/** Simple lane packing: assign each event to first lane that doesn't overlap */ const WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
function packLanes(events: NormalizedClazzEvent[]): NormalizedClazzEvent[][] { function packLanes(events: NormalizedClazzEvent[]): NormalizedClazzEvent[][] {
const sorted = [...events].sort( const sorted = [...events].sort((a, b) => a.startAt.getTime() - b.startAt.getTime());
(a, b) => a.startAt.getTime() - b.startAt.getTime(),
);
const lanes: NormalizedClazzEvent[][] = []; const lanes: NormalizedClazzEvent[][] = [];
for (const ev of sorted) { for (const ev of sorted) {
let placed = false; let placed = false;
@@ -92,66 +70,25 @@ function packLanes(events: NormalizedClazzEvent[]): NormalizedClazzEvent[][] {
break; break;
} }
} }
if (!placed) { if (!placed) lanes.push([ev]);
lanes.push([ev]);
}
} }
return lanes; return lanes;
} }
const WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
export default defineComponent({ export default defineComponent({
name: 'ClazzMatrixView', name: 'ClazzMatrixView',
props: { props: {
/** 标准化事件列表 */
events: { type: Array as PropType<NormalizedClazzEvent[]>, required: true }, events: { type: Array as PropType<NormalizedClazzEvent[]>, required: true },
/** 当前周区间起点 */
weekStart: { type: String as PropType<string>, required: true }, weekStart: { type: String as PropType<string>, required: true },
/** 当前周区间终点 */
weekEnd: { type: String as PropType<string>, required: true }, weekEnd: { type: String as PropType<string>, required: true },
}, },
emits: ['cell-click', 'event-click'], emits: ['cell-click', 'event-click'],
setup(props, { emit }) { setup(props, { emit }) {
const wrapperRef = ref<HTMLDivElement | null>(null); /** 按(dateKey, timeSlotKey)聚合事件 */
const timeSlots = DEFAULT_TIME_SLOTS;
const slotLabels = SLOT_LABELS;
const todayStr = formatDate(new Date(), DateFormatter.Date);
/** 生成列 */
const columns = computed<MatrixColumn[]>(() => {
const start = new Date(props.weekStart);
const end = new Date(props.weekEnd);
const cols: MatrixColumn[] = [];
const cursor = new Date(start);
while (cursor <= end) {
const key = formatDate(cursor, DateFormatter.Date);
cols.push({
dateKey: key,
dateLabel: formatDate(cursor, 'MM-DD'),
weekdayLabel: WEEKDAYS[cursor.getDay()],
isToday: key === todayStr,
isPast: cursor < new Date(todayStr),
});
cursor.setDate(cursor.getDate() + 1);
}
return cols;
});
const colWidth = computed(() => {
if (!wrapperRef.value) return 'auto';
const avail = wrapperRef.value.clientWidth - 1.2 /* slot label */;
return `${avail / columns.value.length}px`;
});
const laneHeight = '0.6rem';
/** events 按 (dateKey, timeSlotKey) 分组 */
const eventsBySlot = computed(() => { const eventsBySlot = 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 (!map[ev.dateKey]) map[ev.dateKey] = {}; if (!map[ev.dateKey]) map[ev.dateKey] = {};
if (!map[ev.dateKey][ev.timeSlotKey]) map[ev.dateKey][ev.timeSlotKey] = []; if (!map[ev.dateKey][ev.timeSlotKey]) map[ev.dateKey][ev.timeSlotKey] = [];
map[ev.dateKey][ev.timeSlotKey].push(ev); map[ev.dateKey][ev.timeSlotKey].push(ev);
@@ -159,16 +96,31 @@ export default defineComponent({
return map; return map;
}); });
/** Lane-packed per slot */ /** 生成列:每个 date×slot 组合一列,仅含课时段 */
const lanesBySlot = computed(() => { const columns = computed<MatrixCol[]>(() => {
const out: Record<string, Record<string, NormalizedClazzEvent[][]>> = {}; const cols: MatrixCol[] = [];
for (const [dateKey, slots] of Object.entries(eventsBySlot.value)) { const start = new Date(props.weekStart);
out[dateKey] = {}; const end = new Date(props.weekEnd);
for (const [slotKey, evs] of Object.entries(slots)) { const cursor = new Date(start);
out[dateKey][slotKey] = packLanes(evs); 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 out; return cols;
}); });
function fmtTime(d: Date): string { function fmtTime(d: Date): string {
@@ -177,7 +129,7 @@ export default defineComponent({
function eventStyle(ev: NormalizedClazzEvent) { function eventStyle(ev: NormalizedClazzEvent) {
return { return {
backgroundColor: ev.color || 'rgba(55, 136, 216, 0.15)', backgroundColor: ev.color || 'rgba(55, 136, 216, 0.12)',
borderLeft: `3px solid ${ev.color || '#3788d8'}`, borderLeft: `3px solid ${ev.color || '#3788d8'}`,
}; };
} }
@@ -190,10 +142,7 @@ export default defineComponent({
emit('event-click', ev); emit('event-click', ev);
} }
return { return { columns, fmtTime, eventStyle, onCellClick, onEventClick };
wrapperRef, timeSlots, slotLabels, columns, colWidth, laneHeight,
eventsBySlot, lanesBySlot, fmtTime, eventStyle, onCellClick, onEventClick,
};
}, },
}); });
</script> </script>
@@ -202,133 +151,101 @@ export default defineComponent({
.matrix-wrapper { .matrix-wrapper {
width: 100%; width: 100%;
height: 100%; height: 100%;
font-size: 0.24rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
font-size: 0.24rem;
overflow: auto;
} }
/* ═══ 双行表头 ═══ */
.matrix-header { .matrix-header {
display: flex; display: flex;
flex-shrink: 0; flex-shrink: 0;
position: sticky; position: sticky;
top: 0; top: 0;
background: #f7f8fa;
z-index: 2; z-index: 2;
background: #f7f8fa;
border-bottom: 1px solid #e8e8e8; border-bottom: 1px solid #e8e8e8;
.header-corner { .header-col {
width: 1.2rem;
flex-shrink: 0; flex-shrink: 0;
padding: 0.15rem 0; width: 2.4rem;
text-align: center; text-align: center;
font-weight: 600;
color: #666;
}
.header-cell {
flex: 1;
text-align: center;
padding: 0.15rem 0;
border-left: 1px solid #f0f0f0; border-left: 1px solid #f0f0f0;
padding: 0.12rem 0.06rem;
&:first-child { border-left: none; }
.header-date { .header-date {
font-weight: 600; font-weight: 600;
font-size: 0.26rem; font-size: 0.26rem;
line-height: 1.5;
} }
.header-weekday { .header-time {
font-size: 0.22rem; font-size: 0.22rem;
color: #999; color: #888;
line-height: 1.4;
} }
&:first-child { border-left: none; }
} }
} }
/* ═══ 表体 ═══ */
.matrix-body { .matrix-body {
display: flex;
flex: 1; flex: 1;
overflow-y: auto; overflow: auto;
.slot-row { .body-col {
flex-shrink: 0;
width: 2.4rem;
padding: 0.08rem;
border-left: 1px solid #f5f5f5;
display: flex; display: flex;
border-bottom: 1px solid #f0f0f0; flex-direction: column;
gap: 0.06rem;
.slot-label { &:first-child { border-left: none; }
width: 1.2rem; }
flex-shrink: 0;
padding: 0.1rem;
text-align: center;
font-size: 0.22rem;
color: #666;
align-self: start;
padding-top: 0.25rem;
}
.slot-cell { .event-lane {
flex: 1; display: flex;
min-height: 0.8rem; gap: 0.06rem;
padding: 0.06rem; }
border-left: 1px solid #f5f5f5;
cursor: pointer;
transition: background-color 0.15s;
&:hover { .event-block {
background-color: #f0f5ff; flex: 1;
} padding: 0.06rem 0.1rem;
border-radius: 0.06rem;
cursor: pointer;
overflow: hidden;
font-size: 0.22rem;
transition: box-shadow 0.15s;
&--today { &:hover { box-shadow: 0 0.04rem 0.12rem rgba(0,0,0,0.15); }
background-color: #fffbe6;
}
&--past { &--ghost { opacity: 0.55; border-style: dashed; }
opacity: 0.6;
}
&--empty { .ev-title { font-weight: 600; font-size: 0.24rem; }
// subtle indication .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; }
}
.event-lane { .slot-empty {
display: flex; flex: 1;
gap: 0.04rem; display: flex;
margin-bottom: 0.04rem; 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;
.event-block { &:hover {
flex: 1; background: #f0f5ff;
padding: 0.04rem 0.08rem; color: #3788d8;
border-radius: 0.06rem;
cursor: pointer;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
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.6;
border-style: dashed;
}
.event-title {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.22rem;
}
.event-meta {
font-size: 0.18rem;
color: #666;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.4;
}
} }
} }
} }