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