7 Commits

Author SHA1 Message Date
weli 80f4d4af46 fix: calendar button focus shadow, date range wrapping, calendar reload on view switch
- Remove FullCalendar button focus shadow to match matrix view buttons
- Allow date range text to wrap to 2 lines with smaller font
- Reload calendar data when switching back from matrix view

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 08:42:49 +08:00
weli ecdf15e26f fix: load matrix data on view switch, align matrix week to Mon-Sun
- Call triggerMatrixSearch() when switching from calendar to matrix view
- Add datesSet early-return in matrix mode to prevent state overwrite
- Add initial mount check for matrix mode
- Align matrix week start to Monday (was Sunday) to match calendar view

The matrix view was not loading data because triggerMatrixSearch was
not called in setViewMode, and FullCalendar's datesSet callback was
overwriting the state date range when switching views. Also the week
started on Sunday vs the calendar's Monday, causing the two views to
display different weeks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 08:36:25 +08:00
weli e2c9f41fd7 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>
2026-05-02 23:26:31 +08:00
weli 513f52fcb3 fix: fit all landscape matrix slots without scrolling
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-02 23:08:58 +08:00
weli c44d763e63 fix: adapt portrait matrix row heights without drift
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-02 22:46:42 +08:00
weli 8d4ed8099c fix: align portrait matrix sidebar with slots
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-02 22:40:05 +08:00
weli b02fa37350 fix: enable natural touch scroll in matrix landscape
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-02 22:33:20 +08:00
4 changed files with 354 additions and 39 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 链,确认改动不会破坏上下游。
+23 -5
View File
@@ -270,6 +270,8 @@ export default defineComponent({
<style scoped lang="less">
@sidebar-width: 1.2rem;
@cell-width: 1.8rem;
@portrait-header-height: 0.72rem;
@portrait-day-row-min-height: 1.16rem;
.matrix-wrapper {
width: 100%;
@@ -300,9 +302,12 @@ export default defineComponent({
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
pointer-events: none; /* allow click-through to cells underneath */
.sidebar-corner {
height: @portrait-header-height;
box-sizing: border-box;
flex-shrink: 0;
background: #f7f8fa;
border-bottom: 1px solid #e8e8e8;
@@ -312,7 +317,8 @@ export default defineComponent({
}
.sidebar-row {
flex: 1;
flex: 1 0 @portrait-day-row-min-height;
min-height: @portrait-day-row-min-height;
display: flex;
flex-direction: column;
align-items: center;
@@ -353,18 +359,20 @@ export default defineComponent({
flex-direction: column;
min-height: 100%;
min-width: fit-content;
box-sizing: border-box;
}
/* ─── 行:共用 ─── */
.sr-header {
display: flex;
height: @portrait-header-height;
flex-shrink: 0;
}
.sr-row {
display: flex;
flex: 1;
min-height: 0;
flex: 1 0 @portrait-day-row-min-height;
min-height: @portrait-day-row-min-height;
}
/* ─── Spacer(占位与 sidebar 同宽) ─── */
@@ -484,6 +492,8 @@ export default defineComponent({
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
.ml-header {
@@ -491,6 +501,9 @@ export default defineComponent({
flex-shrink: 0;
background: #f7f8fa;
border-bottom: 1px solid #e8e8e8;
position: sticky;
top: 0;
z-index: 3;
}
.ml-corner {
@@ -521,13 +534,18 @@ export default defineComponent({
.ml-body {
flex: 1;
overflow-y: auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
overflow-x: hidden;
padding-bottom: 0.08rem;
}
.ml-row {
display: flex;
flex: 1 1 0;
min-height: 0;
border-bottom: 1px solid #f0f0f0;
}
@@ -546,7 +564,7 @@ export default defineComponent({
.ml-cell {
flex: 1;
min-width: 0;
min-height: 0.6rem;
min-height: 0;
display: flex;
flex-direction: row;
gap: 0.02rem;
+62 -30
View File
@@ -11,7 +11,7 @@
<span class="view-toolbar__range" v-else>日历周视图</span>
</div>
<div class="view-toolbar__actions">
<div class="view-toolbar__orient">
<div v-if="viewMode === 'matrix'" class="view-toolbar__orient">
<van-button size="small" plain @click="isLandscape = !isLandscape">
{{ isLandscape ? '退出横屏' : '横屏' }}
</van-button>
@@ -34,23 +34,15 @@
<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>
<van-button 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 size="small" plain @click="isLandscape = false">退出横屏</van-button>
<van-button
:type="viewMode === 'matrix' ? 'primary' : 'default'"
size="small" plain
@@ -59,16 +51,7 @@
</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>
<template v-if="viewMode === 'matrix'">
<div class="landscape-stage-viewport landscape-stage-viewport--matrix">
<div class="landscape-stage">
<div class="matrix-container matrix-container--landscape">
@@ -380,6 +363,10 @@ export default defineComponent({
if (cachedViewMode === 'matrix' || cachedViewMode === 'calendar') {
viewMode.value = cachedViewMode;
}
// 如果初始视图是矩阵,在组件挂载后加载矩阵视图数据
if (viewMode.value === 'matrix') {
nextTick(() => triggerMatrixSearch());
}
const isLandscape = ref(false);
@@ -424,13 +411,15 @@ export default defineComponent({
const matrixWeekStart = computed(() => {
const d = new Date(matrixWeekCursor.value);
const day = d.getDay();
d.setDate(d.getDate() - day);
const diff = day === 0 ? 6 : day - 1; // Monday as first day of week
d.setDate(d.getDate() - diff);
return formatDate(d, DateFormatter.Date);
});
const matrixWeekEnd = computed(() => {
const d = new Date(matrixWeekCursor.value);
const day = d.getDay();
d.setDate(d.getDate() + (6 - day));
const diff = day === 0 ? 6 : day - 1; // Monday as first day of week
d.setDate(d.getDate() + (6 - diff));
return formatDate(d, DateFormatter.Date);
});
const matrixWeekRange = computed(() => `${matrixWeekStart.value} ~ ${matrixWeekEnd.value}`);
@@ -481,7 +470,21 @@ export default defineComponent({
// 切换视图时缓存
const setViewMode = (mode: 'calendar' | 'matrix') => {
viewMode.value = mode;
if (mode === 'calendar') {
isLandscape.value = false;
// 从矩阵切回日历时,按日历当前可视范围加载数据
const api = calendar.value?.getApi?.();
if (api) {
const view = api.view;
state.start_date = formatDate(view.activeStart, DateFormatter.Date);
state.end_date = formatDate(view.activeEnd, DateFormatter.Date);
search();
}
}
setKey('clazz_view_mode', mode);
if (mode === 'matrix') {
triggerMatrixSearch();
}
updateCalendarSize();
};
@@ -774,8 +777,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,
@@ -809,6 +814,8 @@ export default defineComponent({
view(event.id)
},
datesSet: function({start, end}) {
// 矩阵视图有自己的日期范围和数据加载逻辑
if (viewMode.value === 'matrix') return;
if (store.hanging) {
store.hanging = false;
let {state: cachedState} = getKey('clazz_state');
@@ -1102,6 +1109,11 @@ export default defineComponent({
:deep(.fc) {
height: 100%;
}
:deep(.fc-scroller) {
overflow-y: auto !important;
-webkit-overflow-scrolling: touch;
}
}
.calendar-wrapper {
@@ -1127,6 +1139,15 @@ export default defineComponent({
flex-shrink: 0;
}
// 日历按钮与矩阵按钮一致,去掉焦点阴影
:deep(.fc .fc-button-primary:focus),
:deep(.fc .fc-button:focus) {
box-shadow: none !important;
}
:deep(.fc .fc-button-primary:not(:disabled).fc-button-active:focus) {
box-shadow: none !important;
}
:deep(.fc-view-harness) {
height: 100% !important;
min-height: 0;
@@ -1265,7 +1286,15 @@ export default defineComponent({
min-height: 0;
}
:deep(.matrix-sidebar) {
padding-bottom: calc(0.72rem + env(safe-area-inset-bottom));
}
:deep(.matrix-scroll) {
box-sizing: border-box;
}
:deep(.scroll-inner) {
padding-bottom: calc(0.72rem + env(safe-area-inset-bottom));
box-sizing: border-box;
}
@@ -1331,11 +1360,14 @@ export default defineComponent({
&__range {
min-width: 0;
font-size: 0.22rem;
font-size: 0.2rem;
color: #666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
white-space: normal;
overflow: visible;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
&__nav {
@@ -1473,7 +1505,7 @@ export default defineComponent({
.landscape-stage-viewport--matrix {
flex: 1;
min-height: 0;
overflow: auto;
overflow: hidden;
}
.landscape-stage {
+6
View File
@@ -399,7 +399,13 @@ export default function useUser(router?: Router) {
if (currentOrgId) {
window.localStorage.setItem(HtySudoToken, authToken as string);
} else {
try {
await sudo2(userApp.id);
} catch {
if (authToken) {
window.localStorage.setItem(HtySudoToken, authToken);
}
}
}
await getTags();
await getMyTeachers();