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