fix: matrix landscape 90° rotation with fixed header outside rotation

Matrix landscape now uses the same landscape-stage 90° CSS rotation as
the calendar view. The weekday header (ml-header) is rendered outside
the rotated container so it stays fixed at the top. Matrix body
(ml-body) is inside the rotation and scrollable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-02 16:17:17 +08:00
parent a9d5e0844e
commit 0910774a2f
4 changed files with 734 additions and 79 deletions
+4 -4
View File
@@ -63,9 +63,9 @@ export default defineComponent({
const is_tester = computed(() => store.currentRole === HtySuperRoles.TESTER);
const is_supervisor = computed(() => store.currentRole === HtySuperRoles.SUPERVISOR);
const current_role = computed(() => store.currentRole?.toLowerCase())
const is_guest = computed(() => !user.value.is_registered || !user.value.enabled)
console.log(user.value, is_guest.value)
const is_guest = computed(() => {
return !user.value.is_registered || !user.value.enabled;
})
const badge_props: Partial<BadgeProps> = { showZero: false, max: 99 }
@@ -110,7 +110,7 @@ export default defineComponent({
return '/'
})
return {user, is_student, is_teacher, is_admin, is_tester, is_guest, current_role, loading, badge_props, tab_change_check, hide_bottom_tab, home_tab_path}
return {user, is_student, is_teacher, is_supervisor, is_admin, is_tester, is_guest, current_role, loading, badge_props, tab_change_check, hide_bottom_tab, home_tab_path}
}
});
</script>
+240 -22
View File
@@ -1,11 +1,11 @@
<template>
<div class="matrix-wrapper">
<div class="matrix-wrapper" :class="{ 'matrix-wrapper--landscape': landscape }">
<div v-if="!timeSlots.length" class="matrix-empty">
本周暂无排课数据
</div>
<div v-else class="matrix-inner">
<!-- 左侧固定栏拖动时不动 -->
<!-- 竖屏布局天为行时段为列左右滚动 -->
<div v-else-if="!landscape" class="matrix-inner">
<div class="matrix-sidebar">
<div class="sidebar-corner"></div>
<div
@@ -18,11 +18,8 @@
<div class="day-date">{{ day.date }}</div>
</div>
</div>
<!-- 右侧可横向滚动区域 -->
<div class="matrix-scroll">
<div class="scroll-inner">
<!-- Header -->
<div class="sr-header">
<div class="sr-spacer"></div>
<div
@@ -35,8 +32,6 @@
<div class="header-time">{{ slot.time }}</div>
</div>
</div>
<!-- 数据行 -->
<div
v-for="day in weekDays"
:key="day.dateKey"
@@ -69,14 +64,59 @@
</div>
</div>
</div>
<!-- 横屏布局周几为 X 节次为 Y 表头固定上下滚动 -->
<div v-else class="ml-wrap">
<div v-if="!hideLandscapeHeader" class="ml-header">
<div class="ml-corner">时段</div>
<div
v-for="day in weekDays"
:key="day.dateKey"
class="ml-day-header"
:class="{ 'ml-day-header--today': day.isToday }"
>
<div class="day-name">{{ day.name }}</div>
<div class="day-date">{{ day.date }}</div>
</div>
</div>
<div class="ml-body">
<div v-for="slot in timeSlots" :key="slot.key" class="ml-row">
<div class="ml-label">
<div class="header-label">{{ slot.label }}</div>
<div class="header-time">{{ slot.time }}</div>
</div>
<div
v-for="day in weekDays"
:key="`${day.dateKey}_${slot.key}`"
class="ml-cell"
@click="onCellClick(day.dateKey, slot.key)"
>
<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-meta">{{ slot.label }} {{ fmtTime(ev.startAt) }}-{{ fmtTime(ev.endAt) }}</div>
<div class="ev-teacher">{{ ev.teacherName || '未设置老师' }}</div>
<div class="ev-students">{{ ev.studentNames.length ? ev.studentNames.join('、') : '未设置学生' }}</div>
</div>
</template>
<div v-else class="cell-empty">+</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, PropType } from 'vue';
import { NormalizedClazzEvent } from '../useClazzViewModel';
import { SLOT_LABELS } from '../constants';
import { DateFormatter, formatDate } from '~/utils';
import { DateFormatter, formatDate, hexToRgb } from '~/utils';
interface TimeSlot {
key: string;
@@ -103,12 +143,31 @@ function fmtTime(d: Date): string {
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
function toChineseSectionNumber(value: number): string {
const digits = ['', '一', '二', '三', '四', '五', '六', '七', '八', '九'];
if (value <= 10) {
return value === 10 ? '十' : digits[value];
}
if (value < 20) {
return `${digits[value % 10]}`;
}
const tens = Math.floor(value / 10);
const ones = value % 10;
return `${digits[tens] || tens}${ones ? digits[ones] : ''}`;
}
function sectionLabel(index: number): string {
return `${toChineseSectionNumber(index + 1)}`;
}
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 },
landscape: { type: Boolean, default: false },
hideLandscapeHeader: { type: Boolean, default: false },
},
emits: ['cell-click', 'event-click'],
setup(props, { emit }) {
@@ -130,17 +189,18 @@ export default defineComponent({
return days;
});
/** 从排课数据动态计算有时段的列 */
/** 按当前可见课程开始时间动态生成节次。 */
const timeSlots = computed<TimeSlot[]>(() => {
const keys = new Set<string>();
const visibleSlotKeys = new Set<string>();
for (const ev of props.events) {
if (!ev.visible) continue;
keys.add(ev.timeSlotKey);
if (ev.visible) {
visibleSlotKeys.add(ev.timeSlotKey);
}
}
const sorted = Array.from(keys).sort();
return sorted.map((key) => ({
return Array.from(visibleSlotKeys).sort().map((key, index) => ({
key,
label: SLOT_LABELS[key] || key,
label: sectionLabel(index),
time: key,
}));
});
@@ -172,9 +232,16 @@ export default defineComponent({
});
function eventStyle(ev: NormalizedClazzEvent) {
if (ev.color) {
const { r, g, b } = hexToRgb(ev.color);
return {
backgroundColor: `rgba(${r}, ${g}, ${b}, 0.15)`,
borderLeft: `3px solid ${ev.color}`,
};
}
return {
backgroundColor: ev.color || 'rgba(55, 136, 216, 0.12)',
borderLeft: `3px solid ${ev.color || '#3788d8'}`,
backgroundColor: 'rgba(55, 136, 216, 0.12)',
borderLeft: '3px solid #3788d8',
};
}
@@ -186,7 +253,16 @@ export default defineComponent({
emit('event-click', ev);
}
return { timeSlots, weekDays, eventMap, columnWidths, fmtTime, eventStyle, onCellClick, onEventClick };
return {
timeSlots,
weekDays,
eventMap,
columnWidths,
fmtTime,
eventStyle,
onCellClick,
onEventClick,
};
},
});
</script>
@@ -268,14 +344,14 @@ export default defineComponent({
/* ═══ 右侧可滚动区域 ═══ */
.matrix-scroll {
overflow-x: auto;
overflow-y: hidden;
overflow-y: auto;
height: 100%;
}
.scroll-inner {
display: flex;
flex-direction: column;
height: 100%;
min-height: 100%;
min-width: fit-content;
}
@@ -396,4 +472,146 @@ export default defineComponent({
color: #3788d8;
}
}
/* ═══ 横屏布局 ═══ */
.matrix-wrapper--landscape {
width: 100%;
height: 100%;
overflow: hidden;
}
.ml-wrap {
display: flex;
flex-direction: column;
height: 100%;
}
.ml-header {
display: flex;
flex-shrink: 0;
background: #f7f8fa;
border-bottom: 1px solid #e8e8e8;
}
.ml-corner {
width: @sidebar-width;
flex-shrink: 0;
text-align: center;
padding: 0.08rem 0.04rem;
font-size: 0.2rem;
color: #888;
border-right: 1px solid #e8e8e8;
}
.ml-day-header {
flex: 1;
min-width: 0;
text-align: center;
padding: 0.08rem 0.04rem;
border-left: 1px solid #f0f0f0;
.day-name { font-weight: 600; font-size: 0.22rem; line-height: 1.4; }
.day-date { font-size: 0.18rem; color: #888; line-height: 1.3; }
&--today {
background: #e6f7ff;
.day-name { color: #1890ff; }
}
}
.ml-body {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding-bottom: 0.08rem;
}
.ml-row {
display: flex;
border-bottom: 1px solid #f0f0f0;
}
.ml-label {
width: @sidebar-width;
flex-shrink: 0;
text-align: center;
padding: 0.08rem 0.04rem;
background: #fafafa;
border-right: 1px solid #f0f0f0;
.header-label { font-weight: 600; font-size: 0.2rem; line-height: 1.4; }
.header-time { font-size: 0.16rem; color: #888; line-height: 1.3; }
}
.ml-cell {
flex: 1;
min-width: 0;
min-height: 0.6rem;
display: flex;
flex-direction: row;
gap: 0.02rem;
align-items: stretch;
padding: 0.03rem;
border-left: 1px solid #f5f5f5;
overflow: hidden;
.event-block {
flex: 1;
min-width: 0;
padding: 0.04rem 0.06rem;
border-radius: 0.04rem;
cursor: pointer;
overflow: hidden;
.ev-title {
font-weight: 600;
font-size: 0.2rem;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ev-meta {
font-size: 0.16rem;
color: #888;
line-height: 1.3;
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>
+486 -40
View File
@@ -1,42 +1,151 @@
<template>
<div class="clazz-page">
<!-- 视图切换 + 导航工具栏 -->
<div class="view-toolbar">
<div class="view-toolbar__nav" v-if="viewMode === 'matrix'">
<van-button size="small" @click="matrixGoPrev"></van-button>
<van-button size="small" @click="matrixGoToday">本周</van-button>
<van-button size="small" @click="matrixGoNext"></van-button>
<span class="view-toolbar__range">{{ matrixWeekRange }}</span>
<div class="view-toolbar" :class="{ 'view-toolbar--landscape': isLandscape }">
<div class="view-toolbar__main">
<div class="view-toolbar__nav" v-if="viewMode === 'matrix'">
<van-button size="small" @click="matrixGoPrev"></van-button>
<van-button size="small" @click="matrixGoToday">本周</van-button>
<van-button size="small" @click="matrixGoNext"></van-button>
</div>
<span class="view-toolbar__range" v-if="viewMode === 'matrix'">{{ matrixWeekRange }}</span>
<span class="view-toolbar__range" v-else>日历周视图</span>
</div>
<div class="view-toolbar__toggle">
<van-button
:type="viewMode === 'calendar' ? 'primary' : 'default'"
size="small" plain
@click="setViewMode('calendar')"
>日历</van-button>
<van-button
:type="viewMode === 'matrix' ? 'primary' : 'default'"
size="small" plain
@click="setViewMode('matrix')"
>矩阵</van-button>
<div class="view-toolbar__actions">
<div class="view-toolbar__orient">
<van-button size="small" plain @click="isLandscape = !isLandscape">
{{ isLandscape ? '退出横屏' : '横屏' }}
</van-button>
</div>
<div class="view-toolbar__toggle">
<van-button
:type="viewMode === 'calendar' ? 'primary' : 'default'"
size="small" plain
@click="setViewMode('calendar')"
>日历</van-button>
<van-button
:type="viewMode === 'matrix' ? 'primary' : 'default'"
size="small" plain
@click="setViewMode('matrix')"
>矩阵</van-button>
</div>
</div>
</div>
<div v-show="viewMode === 'calendar'" class="calendar" :style="{height: `calc(100% - 1rem - ${footerHeight}px - 0.8rem)`}">
<FullCalendar ref="calendar" :options="options" />
<div v-if="isLandscape" class="landscape-shell">
<div class="landscape-shell__toolbar">
<div class="landscape-shell__left">
<van-button size="small" plain @click="isLandscape = false">退出横屏</van-button>
<van-button v-if="viewMode === 'matrix'" size="small" @click="matrixGoToday">本周</van-button>
</div>
<div class="landscape-shell__title">
<template v-if="viewMode === 'matrix'">
<van-button size="small" @click="matrixGoPrev"></van-button>
<span>{{ matrixWeekRange }}</span>
<van-button size="small" @click="matrixGoNext"></van-button>
</template>
<template v-else>日历周视图</template>
</div>
<div class="landscape-shell__toggle">
<van-button
:type="viewMode === 'calendar' ? 'primary' : 'default'"
size="small" plain
@click="setViewMode('calendar')"
>日历</van-button>
<van-button
:type="viewMode === 'matrix' ? 'primary' : 'default'"
size="small" plain
@click="setViewMode('matrix')"
>矩阵</van-button>
</div>
</div>
<div class="landscape-shell__body" :class="{ 'landscape-shell__body--matrix': viewMode === 'matrix' }">
<template v-if="viewMode === 'calendar'">
<div class="landscape-stage-viewport">
<div class="landscape-stage">
<div class="calendar-wrapper calendar-wrapper--landscape">
<FullCalendar ref="calendar" :options="options" />
</div>
</div>
</div>
</template>
<template v-else>
<div class="landscape-matrix-header">
<div class="lmh-corner">时段</div>
<div
v-for="day in matrixWeekDays"
:key="day.dateKey"
class="lmh-day"
:class="{ 'lmh-day--today': day.isToday }"
>
<div class="day-name">{{ day.name }}</div>
<div class="day-date">{{ day.date }}</div>
</div>
</div>
<div class="landscape-stage-viewport landscape-stage-viewport--matrix">
<div class="landscape-stage">
<div class="matrix-container matrix-container--landscape">
<div v-if="teacherList.length > 1" class="matrix-teacher-filter">
<van-tag
:color="selectedTeacherIds.length === 0 ? '#1989fa' : '#e8e8e8'"
:text-color="selectedTeacherIds.length === 0 ? '#fff' : '#666'"
@click="selectedTeacherIds = []"
>全部</van-tag>
<van-tag
v-for="t in teacherList" :key="t.id"
:color="selectedTeacherIds.length === 0 || selectedTeacherIds.includes(t.id) ? t.color : '#e8e8e8'"
:text-color="selectedTeacherIds.length === 0 || selectedTeacherIds.includes(t.id) ? '#fff' : '#999'"
@click="toggleTeacherFilter(t.id)"
>{{ t.name }}</van-tag>
</div>
<ClazzMatrixView
:events="filteredMatrixEvents"
:week-start="matrixWeekStart"
:week-end="matrixWeekEnd"
:landscape="true"
:hide-landscape-header="true"
@cell-click="onMatrixCellClick"
@event-click="onMatrixEventClick"
/>
</div>
</div>
</div>
</template>
</div>
</div>
<div v-show="viewMode === 'matrix'" class="matrix-container">
<ClazzMatrixView
:events="normalizedEvents"
:week-start="matrixWeekStart"
:week-end="matrixWeekEnd"
@cell-click="onMatrixCellClick"
@event-click="onMatrixEventClick"
/>
</div>
<div class="footer-subsidiary" ref="footer" v-if="subsidiaries.length > 0">
<van-tag type="primary" :color="isTagPlain(item) ? 'rgba(204, 204, 204, 0.5)' : item.color" :text-color="isTagPlain(item) ? item.color : '#fff'" @click="toggleTeacherView(item.to_user_id)" v-for="item in subsidiaries">{{ item.to_user_realname }}</van-tag>
</div>
<template v-else>
<div v-show="viewMode === 'calendar'" class="calendar-wrapper">
<div class="calendar">
<FullCalendar ref="calendar" :options="options" />
</div>
</div>
<div v-show="viewMode === 'matrix'" class="matrix-container">
<div v-if="teacherList.length > 1" class="matrix-teacher-filter">
<van-tag
:color="selectedTeacherIds.length === 0 ? '#1989fa' : '#e8e8e8'"
:text-color="selectedTeacherIds.length === 0 ? '#fff' : '#666'"
@click="selectedTeacherIds = []"
>全部</van-tag>
<van-tag
v-for="t in teacherList" :key="t.id"
:color="selectedTeacherIds.length === 0 || selectedTeacherIds.includes(t.id) ? t.color : '#e8e8e8'"
:text-color="selectedTeacherIds.length === 0 || selectedTeacherIds.includes(t.id) ? '#fff' : '#999'"
@click="toggleTeacherFilter(t.id)"
>{{ t.name }}</van-tag>
</div>
<ClazzMatrixView
:events="filteredMatrixEvents"
:week-start="matrixWeekStart"
:week-end="matrixWeekEnd"
:landscape="false"
@cell-click="onMatrixCellClick"
@event-click="onMatrixEventClick"
/>
</div>
<div class="footer-subsidiary" ref="footer" v-if="subsidiaries.length > 0">
<van-tag type="primary" :color="isTagPlain(item) ? 'rgba(204, 204, 204, 0.5)' : item.color" :text-color="isTagPlain(item) ? item.color : '#333'" @click="toggleTeacherView(item.to_user_id)" v-for="item in subsidiaries">{{ item.to_user_realname }}</van-tag>
</div>
</template>
</div><!-- /.clazz-page -->
<van-overlay :show="state.editing" :lock-scroll="false">
<div class="content-modal">
@@ -186,7 +295,7 @@
</template>
<script lang="ts">
import {reactive, ref, defineComponent, computed} from 'vue';
import {reactive, ref, defineComponent, computed, nextTick, watch} from 'vue';
import {CalendarOptions, DateSelectArg, EventClickArg} from "@fullcalendar/core"
import FullCalendar from '@fullcalendar/vue3'
import dayGridPlugin from '@fullcalendar/daygrid'
@@ -275,7 +384,7 @@ export default defineComponent({
const calendar = ref(null)
const footer = ref(null)
const Colors = ["#ff7a45", "#ffa940", "#ffec3d", "#bae637", "#36cfc9", "#4096ff", "#9254de", "#f759ab"]
const Colors = ["#F4A98C", "#F4C08C", "#ECE68C", "#A8D4A8", "#8CD4D4", "#8CB4E8", "#BC9CE8", "#E89CBC"]
// ═══ 双视图模式 ═══
const viewMode = ref<'calendar' | 'matrix'>('calendar');
@@ -285,7 +394,45 @@ export default defineComponent({
viewMode.value = cachedViewMode;
}
const isLandscape = ref(false);
const updateCalendarSize = () => {
nextTick(() => {
requestAnimationFrame(() => {
calendar.value?.getApi?.().updateSize();
});
});
};
watch([isLandscape, viewMode], updateCalendarSize);
// 矩阵视图导航
const WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
function isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth()
&& a.getDate() === b.getDate();
}
const today = computed(() => new Date());
const matrixWeekDays = computed(() => {
const days: { dateKey: string; name: string; date: string; isToday: boolean }[] = [];
const start = new Date(matrixWeekStart.value);
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 matrixWeekCursor = ref(new Date());
const matrixWeekStart = computed(() => {
const d = new Date(matrixWeekCursor.value);
@@ -313,10 +460,42 @@ export default defineComponent({
},
});
// ═══ 老师过滤(矩阵视图用) ═══
const teacherList = computed(() => {
const list: { id: string; name: string; color: string }[] = [];
const myId = usingUser.store.current.hty_id;
const myName = usingUser.store.current.real_name;
if (myId && myName) {
list.push({ id: myId, name: myName, color: '#4096ff' });
}
subsidiaries.value.forEach((s, i) => {
list.push({
id: s.to_user_id,
name: s.to_user_realname,
color: Colors[i % Colors.length],
});
});
return list;
});
const selectedTeacherIds = ref<string[]>([]);
const filteredMatrixEvents = computed(() => {
if (selectedTeacherIds.value.length === 0) return normalizedEvents.value;
return normalizedEvents.value.filter(ev => {
const id = ev.isSubsidiary ? ev.subsidiaryTeacherId : ev.teacherId;
return id ? selectedTeacherIds.value.includes(id) : true;
});
});
const toggleTeacherFilter = (id: string) => {
const idx = selectedTeacherIds.value.indexOf(id);
if (idx >= 0) selectedTeacherIds.value.splice(idx, 1);
else selectedTeacherIds.value.push(id);
};
// 切换视图时缓存
const setViewMode = (mode: 'calendar' | 'matrix') => {
viewMode.value = mode;
setKey('clazz_view_mode', mode);
updateCalendarSize();
};
const timePickerFilter = (type, options) => {
@@ -909,8 +1088,9 @@ export default defineComponent({
replyComment, removeComment, saveComment, replying, commenting, comments_count, viewingSubsidiaries,
openAttendance, submitAttendance, attendanceMap, attendanceStudents,
showAuditLogPopup, auditActionTagType, auditLogs,
viewMode, setViewMode, normalizedEvents,
matrixWeekStart, matrixWeekEnd, matrixWeekRange,
viewMode, setViewMode, normalizedEvents, filteredMatrixEvents,
teacherList, selectedTeacherIds, toggleTeacherFilter,
isLandscape, matrixWeekStart, matrixWeekEnd, matrixWeekRange, matrixWeekDays,
matrixGoPrev, matrixGoNext, matrixGoToday,
onMatrixCellClick, onMatrixEventClick,
}
@@ -919,14 +1099,55 @@ export default defineComponent({
</script>
<style scoped lang="less">
@sidebar-width: 1.2rem;
@cell-width: 1.8rem;
.calendar {
font-size: 10px;
padding: 8px;
height: calc(100% - 1rem);
box-sizing: border-box;
height: 100%;
min-height: 0;
:deep(.fc-toolbar-title) {
font-size: 1.25em;
}
:deep(.fc) {
height: 100%;
}
}
.calendar-wrapper {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.calendar-wrapper--landscape {
width: 100%;
height: 100%;
box-sizing: border-box;
:deep(.fc) {
width: 100%;
height: 100%;
min-height: 100%;
}
:deep(.fc-header-toolbar) {
flex-shrink: 0;
}
:deep(.fc-view-harness) {
height: 100% !important;
min-height: 0;
}
:deep(.fc-scroller) {
overflow: auto !important;
}
}
.footer-subsidiary {
@@ -1046,8 +1267,50 @@ export default defineComponent({
.matrix-container {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: #fff;
:deep(.matrix-wrapper) {
flex: 1;
min-height: 0;
}
:deep(.matrix-scroll) {
padding-bottom: calc(0.72rem + env(safe-area-inset-bottom));
box-sizing: border-box;
}
}
.matrix-container--landscape {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
:deep(.matrix-scroll) {
padding-bottom: 0;
}
:deep(.ml-body) {
padding-bottom: calc(0.2rem + env(safe-area-inset-bottom));
}
}
.matrix-teacher-filter {
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
gap: 0.06rem;
padding: 0.08rem 0.12rem;
border-bottom: 1px solid #f0f0f0;
:deep(.van-tag) {
cursor: pointer;
}
}
/* ═══ 双视图工具栏 ═══ */
@@ -1056,10 +1319,38 @@ export default defineComponent({
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.08rem;
padding: 0.08rem 0.12rem;
background: #fff;
border-bottom: 1px solid #f0f0f0;
&--landscape {
display: none;
}
&__main {
min-width: 0;
display: flex;
align-items: center;
gap: 0.08rem;
}
&__actions {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 0.08rem;
}
&__range {
min-width: 0;
font-size: 0.22rem;
color: #666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__nav {
display: flex;
align-items: center;
@@ -1067,14 +1358,21 @@ export default defineComponent({
.van-button--small {
height: 0.5rem;
min-width: 0.62rem;
padding: 0 0.15rem;
font-size: 0.24rem;
white-space: nowrap;
}
.view-toolbar__range {
font-size: 0.22rem;
}
&__orient {
.van-button--small {
height: 0.5rem;
padding: 0 0.15rem;
font-size: 0.24rem;
border-color: #c8c9cc;
color: #666;
margin-left: 0.1rem;
}
}
@@ -1096,4 +1394,152 @@ export default defineComponent({
}
}
}
.landscape-shell {
--landscape-toolbar-height: 0.68rem;
--clazz-landscape-body-height: calc(100dvh - var(--landscape-toolbar-height));
position: fixed;
inset: 0;
z-index: 80;
display: flex;
flex-direction: column;
background: #fff;
}
.landscape-shell__toolbar {
flex-shrink: 0;
min-height: var(--landscape-toolbar-height);
box-sizing: border-box;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.08rem;
padding: 0.08rem 0.12rem;
border-bottom: 1px solid #f0f0f0;
background: #fff;
}
.landscape-shell__left {
display: flex;
align-items: center;
gap: 0.08rem;
}
.landscape-shell__title {
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 0.08rem;
color: #333;
font-size: 0.24rem;
font-weight: 600;
span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.landscape-shell__toggle {
display: flex;
flex-shrink: 0;
gap: 0;
.van-button {
height: 0.5rem;
font-size: 0.24rem;
border-radius: 0;
&:first-child {
border-radius: 0.06rem 0 0 0.06rem;
}
&:last-child {
border-radius: 0 0.06rem 0.06rem 0;
}
}
}
.landscape-shell__body {
flex: 1;
min-height: 0;
overflow: hidden;
height: var(--clazz-landscape-body-height);
&--matrix {
display: flex;
flex-direction: column;
overflow: hidden;
}
}
.landscape-stage-viewport {
width: 100%;
height: 100%;
overflow: hidden;
background: #fff;
}
.landscape-stage-viewport--matrix {
flex: 1;
min-height: 0;
overflow: auto;
}
.landscape-stage {
width: var(--clazz-landscape-body-height);
height: 100dvw;
overflow: hidden;
transform: rotate(90deg) translateY(-100%);
transform-origin: top left;
}
/* ═══ 横屏矩阵表头(固定在旋转容器外部)═══ */
.landscape-matrix-header {
flex-shrink: 0;
display: flex;
background: #f7f8fa;
border-bottom: 1px solid #e8e8e8;
}
.landscape-matrix-header .lmh-corner {
width: @sidebar-width;
flex-shrink: 0;
text-align: center;
padding: 0.08rem 0.04rem;
font-size: 0.2rem;
color: #888;
border-right: 1px solid #e8e8e8;
}
.landscape-matrix-header .lmh-day {
flex: 1;
min-width: 0;
text-align: center;
padding: 0.08rem 0.04rem;
border-left: 1px solid #f0f0f0;
}
.landscape-matrix-header .lmh-day .day-name {
font-weight: 600;
font-size: 0.22rem;
line-height: 1.4;
}
.landscape-matrix-header .lmh-day .day-date {
font-size: 0.18rem;
color: #888;
line-height: 1.3;
}
.landscape-matrix-header .lmh-day--today {
background: #e6f7ff;
}
.landscape-matrix-header .lmh-day--today .day-name {
color: #1890ff;
}
</style>
+4 -13
View File
@@ -1,6 +1,5 @@
import { computed, ComputedRef } from 'vue';
import { Clazz, ClazzRepeatRow } from '~/types';
import { DEFAULT_TIME_SLOTS } from './constants';
export interface NormalizedClazzEvent {
id: string;
@@ -23,7 +22,7 @@ export interface NormalizedClazzEvent {
__raw: Clazz | ClazzRepeatRow;
/** 日期键 YYYY-MM-DD */
dateKey: string;
/** 时段键 HH:mm — 匹配最近的固定时段 */
/** 时段键 HH:mm,按课程真实开始时间生成 */
timeSlotKey: string;
}
@@ -37,16 +36,8 @@ function dateKey(d: string | Date): string {
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
}
/** 将时间(分)对齐到最近的固定时段 key */
function nearestSlotKey(d: Date): string {
const minutes = d.getHours() * 60 + d.getMinutes();
const slots = DEFAULT_TIME_SLOTS.map((s) => {
const [h, m] = s.split(':').map(Number);
return { key: s, minutes: h * 60 + m };
});
// Sort by proximity to the event start time
slots.sort((a, b) => Math.abs(a.minutes - minutes) - Math.abs(b.minutes - minutes));
return slots[0]?.key ?? DEFAULT_TIME_SLOTS[0];
function timeSlotKey(d: Date): string {
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
function pickNames(item: Clazz) {
@@ -88,7 +79,7 @@ function makeEvent(
borderColor: extra?.borderColor,
__raw: item,
dateKey: dateKey(startAt),
timeSlotKey: nearestSlotKey(startAt),
timeSlotKey: timeSlotKey(startAt),
};
}