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