fix(read): handle sudo2 failure gracefully in read()

When sudo2() fails (e.g. CI user whose login JWT lacks current_org_id),
fall back to using the auth token as the sudo token instead of propagating
the error. This prevents the route guard from aborting org/department
loading, allowing subsequent API calls (loadMyDepartments, etc.) to proceed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-02 23:26:31 +08:00
parent 513f52fcb3
commit e2c9f41fd7
3 changed files with 275 additions and 3 deletions
+259
View File
@@ -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 链,确认改动不会破坏上下游。
+9 -2
View File
@@ -760,8 +760,10 @@ export default defineComponent({
plugins: [ dayGridPlugin, bootstrap5Plugin, timeGridPlugin, interactionPlugin ], plugins: [ dayGridPlugin, bootstrap5Plugin, timeGridPlugin, interactionPlugin ],
initialView: 'timeGridWeek', initialView: 'timeGridWeek',
initialDate: initialDate, initialDate: initialDate,
slotMinTime: "06:00:00", slotMinTime: "00:00:00",
slotMaxTime: "22:00:00", slotMaxTime: "24:00:00",
scrollTime: "06:00:00",
allDaySlot: false,
nowIndicator: true, nowIndicator: true,
droppable: true, droppable: true,
editable: true, editable: true,
@@ -1088,6 +1090,11 @@ export default defineComponent({
:deep(.fc) { :deep(.fc) {
height: 100%; height: 100%;
} }
:deep(.fc-scroller) {
overflow-y: auto !important;
-webkit-overflow-scrolling: touch;
}
} }
.calendar-wrapper { .calendar-wrapper {
+6
View File
@@ -399,7 +399,13 @@ export default function useUser(router?: Router) {
if (currentOrgId) { if (currentOrgId) {
window.localStorage.setItem(HtySudoToken, authToken as string); window.localStorage.setItem(HtySudoToken, authToken as string);
} else { } else {
try {
await sudo2(userApp.id); await sudo2(userApp.id);
} catch {
if (authToken) {
window.localStorage.setItem(HtySudoToken, authToken);
}
}
} }
await getTags(); await getTags();
await getMyTeachers(); await getMyTeachers();