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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user