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