diff --git a/src/pages/clazz/LANDSCAPE_MATRIX_ARCH.md b/src/pages/clazz/LANDSCAPE_MATRIX_ARCH.md new file mode 100644 index 0000000..e16d87c --- /dev/null +++ b/src/pages/clazz/LANDSCAPE_MATRIX_ARCH.md @@ -0,0 +1,259 @@ +# 横屏矩阵视图架构说明 + +## 目录 + +- [核心问题](#核心问题) +- [架构总览](#架构总览) +- [CSS 旋转原理](#css-旋转原理) +- [布局策略详解](#布局策略详解) +- [滚动方案](#滚动方案) +- [修改指南](#修改指南) +- [常见陷阱](#常见陷阱) + +--- + +## 核心问题 + +横屏模式下,矩阵视图需要: + +1. **旋转 90°** 来利用横屏空间:手机横屏时物理宽度大、高度小,课表需要填满这个空间 +2. **表头固定**:周二~周日那一行在滚动时一直可见 +3. **纵向滚动**:所有课时(第 1 节~第 N 节)都能通过滚动看到 +4. **无多余空白/截断** + +关键矛盾:CSS `transform: rotate(90deg)` 旋转后,元素的**坐标方向和视觉方向不再一致**。overflow、position: sticky、scrollTop 都在元素自身坐标系工作,而用户看到的是旋转后的视觉效果。处理不当就会出现"滚动不到底"、"表头跟着跑"、"出现多余空白"等问题。 + +--- + +## 架构总览 + +``` +index.vue +┌──────────────────────────────────────────────────────┐ +│ landscape-shell (position: fixed; inset: 0) │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ landscape-shell__toolbar ← 退出横屏 / 本周等 │ │ +│ ├──────────────────────────────────────────────────┤ │ +│ │ landscape-shell__body (overflow: hidden) │ │ +│ │ ┌──────────────────────────────────────────────┐ │ │ +│ │ │ landscape-stage-viewport--matrix │ │ │ +│ │ │ (overflow: hidden — 只负责裁剪) │ │ │ +│ │ │ ┌──────────────────────────────────────────┐ │ │ │ +│ │ │ │ landscape-stage (rotate 90°) │ │ │ +│ │ │ │ ┌──────────────────────────────────────┐ │ │ │ +│ │ │ │ │ matrix-container--landscape │ │ │ │ +│ │ │ │ │ ┌──────────────────────────────────┐ │ │ │ │ +│ │ │ │ │ │ ClazzMatrixView │ │ │ │ │ +│ │ │ │ │ │ ┌──────────────────────────────┐ │ │ │ │ │ +│ │ │ │ │ │ │ ml-wrap (flex column) │ │ │ │ │ │ +│ │ │ │ │ │ │ ├─ ml-header (sticky top) │ │ │ │ │ │ +│ │ │ │ │ │ │ └─ ml-body (overflow-y: auto) │ │ │ │ │ │ +│ │ │ │ │ │ │ ├─ ml-row (第1节: A B C..)│ │ │ │ │ │ +│ │ │ │ │ │ │ ├─ ml-row (第2节: A B C..)│ │ │ │ │ │ +│ │ │ │ │ │ │ └─ ml-row (第N节: ...) │ │ │ │ │ │ +│ │ │ │ │ │ └──────────────────────────────┘ │ │ │ │ │ +│ │ │ │ │ └──────────────────────────────────┘ │ │ │ │ +│ │ │ │ └──────────────────────────────────────┘ │ │ │ +│ │ │ └──────────────────────────────────────────┘ │ │ +│ │ └──────────────────────────────────────────────┘ │ +│ └──────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────┘ +``` + +### 关键层次职责 + +| 层 | CSS | 职责 | +|---|---|---| +| `landscape-shell__body` | `overflow: hidden` | 裁剪旋转容器,不滚动 | +| `landscape-stage-viewport--matrix` | `overflow: hidden` | 裁剪旋转容器,不滚动 | +| `landscape-stage` | `overflow: visible` | 旋转舞台,内容可见即可 | +| `ml-wrap` | `overflow: hidden` | 包裹 header+body,裁剪 | +| `ml-header` | `position: sticky; top: 0` | 表头固定在滚动容器顶部 | +| `ml-body` | `overflow-y: auto` | **唯一滚动容器** | + +--- + +## CSS 旋转原理 + +### 旋转公式 + +```css +.landscape-stage { + width: var(--clazz-landscape-body-height); /* 约 325px */ + height: 100dvw; /* 约 812px */ + transform: rotate(90deg) translateY(-100%); + transform-origin: top left; +} +``` + +- `width` = body-height(约 325px),`height` = 100dvw(约 812px) +- 旋转后变成视觉上 812×325,填满横屏 +- `transform-origin: top left` + `translateY(-100%)` 纠正旋转后的位置偏移 + +### 旋转后的坐标系变化 + +旋转前(元素自身坐标系 → 视觉方向): + +| 元素方向 | 视觉方向 | +|----------|----------| +| +X(向右) | → 视觉右侧 | +| +Y(向下) | → 视觉下方 | + +旋转 90° CW 后: + +| 元素方向 | 视觉方向 | +|----------|----------| +| +X(向右) | → 视觉下方 | +| +Y(向下) | → 视觉左侧 | + +**关键理解**:overflow、scrollTop、position: sticky 全部在**元素自身坐标系**工作,不受 transform 影响。 + +### ml-* 布局在旋转前后的映射 + +旋转前 ml-wrap 布局(元素坐标系): + +``` +┌──────┬──────────────────────────────┐ ← ml-header (元素顶部) +│ 时段 │ 周一 周二 周三 周四 ... │ flex row, 元素 X 方向排列 +├──────┼──────────────────────────────┤ +│ 第1节 │ A1 B1 C1 D1 ... │ ← ml-row +│ 第2节 │ A2 B2 C2 D2 ... │ ← ml-row +│ 第3节 │ A3 B3 C3 D3 ... │ ← ml-row +│ ... │ │ +└──────┴──────────────────────────────┘ + ↑ ↑ + 元素 X 方向 元素 X 方向 + (label) (day cells) +``` + +旋转 90° CW 后的视觉效果: + +``` + ←←← 元素 Y 方向 = 视觉左侧(更多课时往左延伸) + ↓ ┌──────┬──────┬──────┬──────┐ + 元 │ 时段 │ 周一 │ 周二 │ 周三 │ ... → ml-header (sticky top) + 素 视觉 ├──────┼──────┼──────┼──────┤ + X ↓ │ 第1节 │ A1 │ B1 │ C1 │ ... + ↓ 下方 ├──────┼──────┼──────┼──────┤ + │ 第2节 │ A2 │ B2 │ C2 │ ... + ├──────┼──────┼──────┼──────┤ + │ ... │ │ │ │ + └──────┴──────┴──────┴──────┘ +``` + +--- + +## 滚动方案 + +### 为什么浏览器原生滚动不够 + +旋转后,`overflow-y: auto` 的滚动方向(元素 Y = 视觉左侧)和用户期望的滚动方向(视觉下方)不一致。所以需要手动接管。 + +### 手动滚动实现 + +```ts +// ml-body 元素上绑定的事件 +@touchstart="onLandscapeBodyTouchStart" +@touchmove.prevent="onLandscapeBodyTouchMove" +@wheel="onLandscapeBodyWheel" + +// touchmove: 手指上下滑动 → 控制元素 scrollTop +function onLandscapeBodyTouchMove(e: TouchEvent) { + const currentY = e.touches[0]?.clientY; + const previousY = landscapeTouchLastY.value; + const body = landscapeBodyRef.value; + if (currentY == null || previousY == null || !body) return; + body.scrollTop += currentY - previousY; // 直接操作元素 scrollTop + landscapeTouchLastY.value = currentY; +} + +// wheel: 鼠标滚轮 → 控制元素 scrollTop +function onLandscapeBodyWheel(e: WheelEvent) { + const body = landscapeBodyRef.value; + if (!body) return; + body.scrollTop += e.deltaY; // 直接操作元素 scrollTop +} +``` + +关键点: +- **直接操作 `element.scrollTop`**,不走浏览器的 scroll 行为 +- scrollTop 在元素自身坐标系工作,不管视觉方向 +- `touchmove.prevent` 阻止浏览器默认行为(浏览器会尝试在 viewport 层滚动) +- CSS `touch-action: none` + `overscroll-behavior: contain` 进一步禁止浏览器介入 + +### sticky header 为什么能用 + +```css +.ml-header { + position: sticky; + top: 0; /* 元素自身坐标系的 top,不是视觉 top */ +} +``` + +sticky 在**滚动容器的坐标系**中计算。ml-header 的滚动容器是 ml-body(最近的有 overflow 的父元素)。ml-body 的 scrollTop 在元素 Y 方向 = 旋转后的视觉左侧方向。sticky 的 `top: 0` 意味着"在元素 Y 方向离顶部 0px 处固定"——旋转后正好映射到视觉顶部。 + +这就是为什么 sticky header 必须在旋转内部,不能在外部。 + +--- + +## 修改指南 + +### 如果要改表头样式 + +改 `ClazzMatrixView.vue` 的 `ml-header` / `ml-day-header` 相关 CSS。**不要**在 `index.vue` 里加外部表头。 + +### 如果要改滚动行为 + +改 `ClazzMatrixView.vue` 的 `ml-body` CSS 和 `onLandscapeBodyTouchMove` / `onLandscapeBodyWheel` 函数。**不要**在 viewport 层设 `overflow: auto`。 + +### 如果要调整布局层次 + +记住这条规则:**"谁包含内容,谁负责滚动"**。内容(ml-row)在 ml-body 里 → ml-body 负责滚动。viewport 只负责裁剪(overflow: hidden)。不在 viewport 层滚动。 + +### 如果要改旋转角度 + +`landscape-stage` 的 `rotate(90deg)` 是整个策略的基础。如果要改成其他角度(例如 -90deg),坐标系映射方向全部会变,需要重新审视 scroll handler 的实现。 + +--- + +## 常见陷阱 + +### 陷阱 1:把表头放旋转容器外 + +症状:表头和内容宽度对不齐、位置偏移、横竖坐标不对应。 + +原因:旋转后的元素宽度不等于 CSS 上写的 width(因为 transform 不影响 layout box)。外部固定元素无法和旋转后内部元素对齐。 + +**解法**:表头放旋转容器内部,用 sticky。 + +### 陷阱 2:在 viewport 层设 overflow: auto + +症状:出现大量空白可滚动区域、滚不到底。 + +原因:viewport 不在旋转容器内部,它看到的 stage flow box 尺寸是 `width: 325px; height: 812px`(**旋转前的尺寸**)。812px 的可滚动高度远大于视觉上 325px 的显示区域,所以很多是空白。 + +**解法**:viewport 固定 = `overflow: hidden`,滚动容器在内部 ml-body。 + +### 陷阱 3:试图去掉旋转改用其他方案 + +症状:布局显示方向完全不对。 + +原因:横屏模式的核心思想就是用旋转来利用物理横屏空间。去掉旋转 = 失去横屏适配。 + +**解法**:保留旋转,在旋转内部解决问题。 + +### 陷阱 4:用 CSS overflow 的自动滚动而不手动接管 + +症状:滚动方向错乱(上下滑变成左右滚动)。 + +原因:旋转后 overflow-y 对应视觉左侧方向。浏览器不会自动映射方向。 + +**解法**:手动接管 touch/wheel 事件,直接操作 scrollTop。 + +### 陷阱 5:修改 overflow 链中的某一层而不检查上下游 + +症状:内容被裁剪、滚动失效、sticky 不工作。 + +原因:overflow 链是层叠的:viewport(overflow:hidden) → stage(overflow:visible) → ml-wrap(overflow:hidden) → ml-body(overflow-y:auto)。任何一层改了,都会影响滚动和可见范围。 + +**解法**:改之前理解整个 overflow 链,确认改动不会破坏上下游。 diff --git a/src/pages/clazz/index.vue b/src/pages/clazz/index.vue index 71e97e8..31ac76c 100644 --- a/src/pages/clazz/index.vue +++ b/src/pages/clazz/index.vue @@ -760,8 +760,10 @@ export default defineComponent({ plugins: [ dayGridPlugin, bootstrap5Plugin, timeGridPlugin, interactionPlugin ], initialView: 'timeGridWeek', initialDate: initialDate, - slotMinTime: "06:00:00", - slotMaxTime: "22:00:00", + slotMinTime: "00:00:00", + slotMaxTime: "24:00:00", + scrollTime: "06:00:00", + allDaySlot: false, nowIndicator: true, droppable: true, editable: true, @@ -1088,6 +1090,11 @@ export default defineComponent({ :deep(.fc) { height: 100%; } + + :deep(.fc-scroller) { + overflow-y: auto !important; + -webkit-overflow-scrolling: touch; + } } .calendar-wrapper { diff --git a/src/store/user.ts b/src/store/user.ts index d6541e7..5a3d714 100644 --- a/src/store/user.ts +++ b/src/store/user.ts @@ -399,7 +399,13 @@ export default function useUser(router?: Router) { if (currentOrgId) { window.localStorage.setItem(HtySudoToken, authToken as string); } else { - await sudo2(userApp.id); + try { + await sudo2(userApp.id); + } catch { + if (authToken) { + window.localStorage.setItem(HtySudoToken, authToken); + } + } } await getTags(); await getMyTeachers();