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