17 Commits

Author SHA1 Message Date
weli 29db1b75ff fix: FullCalendar getApi null 引用保护
Frontend CI / build (push) Failing after 14m22s
draw()/redraw()/freeze() 在组件卸载后可能被异步触发,
添加 calendar.value null 检查防止 TypeError。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 10:35:51 +08:00
weli 398ee9acfc fix: landscape toolbar date range style matches regular toolbar
Replaces bold centered date with nav arrows in landscape matrix view
with the same view-toolbar__nav + view-toolbar__range pattern used
by the regular toolbar (lighter font, wrapping text, consistent sizing).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 10:35:37 +08:00
weli 766f511e11 fix: SUPERVISOR 角色打卡查询未设置 teacher_id,添加 debug 日志
- daka store query() SUPERVISOR 与 TEACHER 同等处理
- 添加 console.debug 日志便于 vConsole 排查

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 10:33:06 +08:00
weli 78c89cd2cc fix: 主管教师首页改为 /teacher/home,非 /supervisor/subsidiaries
主管教师与教师共享 tabbar,首页应为本机构正常工作台,
而非"我的下属老师"页面。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 09:48:54 +08:00
weli 1bca764beb fix: vConsole 异步加载 tags 后不显示的问题
getTags() 在 setCurrentUser() 之后异步执行,原 watch 无 deep:true
导致 store.current.tags 被 mutation 后不触发可见性更新。

- 原 watch 添加 deep:true
- 提取 updateVConsoleVisibility 函数
- 新增独立 watch 监听 store.current.tags 变化

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 09:44:03 +08:00
weli 3373c29506 perf: cache last query range, parallelize API calls to reduce loading latency
- Add lastQueryKey to skip redundant API calls when same date range is re-queried
- Run query / query_repeats / subsidiary queries in parallel with Promise.all
- Force-refresh cache after CUD operations via search(true)
- Update searchForSubsidiaries to accept params for independent invocation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 09:22:37 +08:00
weli 4dafdf0bb1 fix: change calendar v-show to v-if to prevent FC layout break on view switch
FC mis-calculates column widths when switching from hidden (v-show: none)
to visible. Using v-if forces full remount with correct container dimensions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 09:18:14 +08:00
weli df66e768f3 fix: merge nav into single toolbar for both views, hide FC headerToolbar
- Remove FC headerToolbar and its customButtons (prev/today/next/createClazz)
- Add calendar navigation (navGoPrev/Today/Next) reading FC api
- Unified .view-toolbar with nav + range for both calendar and matrix
- calendarDateRange ref updated in datesSet, weekRange computed dispatches per view
- Remove dead FC button CSS

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 09:14:27 +08:00
weli e93f844a61 fix: move matrix nav/range into own toolbar below view-toolbar, matching calendar layout
- .view-toolbar now only has view toggle (and orient button for matrix)
- Matrix nav buttons (‹ 本周 ›) and date range moved to .matrix-toolbar inside matrix-page
- Calender keeps FC's built-in headerToolbar — both views now share same layout pattern

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 09:09:23 +08:00
weli fc8d7de10b fix: style FullCalendar buttons as white-outlined to match matrix view
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 08:59:36 +08:00
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
9 changed files with 473 additions and 141 deletions
+7
View File
@@ -0,0 +1,7 @@
# huike-front 项目指南
## vConsole 显示规则
vConsole 仅在当前用户有 `SYS_CAN_SUDO` tag 时显示。判断逻辑在 `src/App.vue:updateVConsoleVisibility()`
Tags 通过 `get_all_tags_of_the_user` API 异步加载(`src/store/user.ts:getTags()`),在 `setCurrentUser()` 之后执行。vConsole 的 watch 需要 `{ deep: true }` 以及独立的 `store.current.tags` watch 来覆盖异步加载完成后的触发。
+12 -1
View File
@@ -77,6 +77,10 @@ export default defineComponent({
})
watch(() => store.current, () => {
updateVConsoleVisibility();
}, { deep: true })
function updateVConsoleVisibility() {
// vConsole 仅对 sudoer(有 SYS_CAN_SUDO tag)用户可见
let showVConsole = store.current.tags?.some(t => t.tag_name === 'SYS_CAN_SUDO') ?? false;
console.log('vconsole visibility (sudoer only)...', { showVConsole, tags: store.current.tags });
@@ -84,6 +88,13 @@ export default defineComponent({
if (vconsole) {
vconsole.hidden = !showVConsole;
}
}
// 同时监听 tags 的独立变化(getTags 异步加载完成后触发)
watch(() => store.current.tags, (val) => {
if (val && val.length > 0) {
updateVConsoleVisibility();
}
})
const tab_change_check = (name: number | string) => {
@@ -106,7 +117,7 @@ export default defineComponent({
if (role === HtyBaseRoles.TEACHER) return '/teacher/home'
if (role === HtySuperRoles.ADMIN) return '/admin/teachers'
if (role === HtySuperRoles.TESTER) return '/tester'
if (role === HtySuperRoles.SUPERVISOR) return '/supervisor/subsidiaries'
if (role === HtySuperRoles.SUPERVISOR) return '/teacher/home'
return '/'
})
+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;
+158 -131
View File
@@ -2,16 +2,15 @@
<div class="clazz-page">
<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 class="view-toolbar__nav">
<van-button size="small" @click="navGoPrev"></van-button>
<van-button size="small" @click="navGoToday">本周</van-button>
<van-button size="small" @click="navGoNext"></van-button>
</div>
<span class="view-toolbar__range" v-if="viewMode === 'matrix'">{{ matrixWeekRange }}</span>
<span class="view-toolbar__range" v-else>日历周视图</span>
<span class="view-toolbar__range">{{ weekRange }}</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>
@@ -33,24 +32,14 @@
<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 class="view-toolbar__nav">
<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">{{ matrixWeekRange }}</span>
<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 +48,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">
@@ -101,34 +81,34 @@
</div>
<template v-else>
<div v-show="viewMode === 'calendar'" class="calendar-wrapper">
<div v-if="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 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>
<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>
@@ -380,6 +360,10 @@ export default defineComponent({
if (cachedViewMode === 'matrix' || cachedViewMode === 'calendar') {
viewMode.value = cachedViewMode;
}
// 如果初始视图是矩阵,在组件挂载后加载矩阵视图数据
if (viewMode.value === 'matrix') {
nextTick(() => triggerMatrixSearch());
}
const isLandscape = ref(false);
@@ -424,17 +408,44 @@ 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}`);
// 日历视图日期范围(由 datesSet 更新)
const calendarDateRange = ref('');
// 统一切换:矩阵用 matrixWeekRange,日历时用 calendarDateRange
const weekRange = computed(() =>
viewMode.value === 'matrix' ? matrixWeekRange.value : calendarDateRange.value
);
// 导航按钮分发(日历 / 矩阵)
const navGoPrev = () => {
if (viewMode.value === 'matrix') return matrixGoPrev();
const api = calendar.value?.getApi?.();
api?.prev();
// datesSet 会自动更新 calendarDateRange
};
const navGoToday = () => {
if (viewMode.value === 'matrix') return matrixGoToday();
calendar.value?.getApi?.().gotoDate(new Date());
};
const navGoNext = () => {
if (viewMode.value === 'matrix') return matrixGoNext();
const api = calendar.value?.getApi?.();
api?.next();
};
// 标准化事件模型(供矩阵视图消费)
const {normalizedEvents} = useClazzViewModel({
list: computed(() => store.list),
@@ -481,7 +492,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();
};
@@ -538,6 +563,7 @@ export default defineComponent({
};
const draw = () => {
if (!calendar.value) { console.debug('[clazz draw] skip, calendar null'); return; }
let api = calendar.value.getApi()
api.removeAllEvents();
store.list.forEach(item => {
@@ -599,6 +625,7 @@ export default defineComponent({
}
const redraw = () => {
if (!calendar.value) { console.debug('[clazz redraw] skip, calendar null'); return; }
let events = calendar.value.getApi().getEvents();
events.forEach((event, i) => {
let subsidiary_id = event.extendedProps.subsidiary_id;
@@ -662,7 +689,10 @@ export default defineComponent({
const comments_count = computed(() => count_comments(store.current.id))
const searchForSubsidiaries = async () => {
// 相同日期范围跳过重复 API 调用
const lastQueryKey = ref('');
const searchForSubsidiaries = async (start_date: string, end_date: string) => {
// 主管老师角色下才加载下属老师的排课
if (usingUser.store.currentRole !== HtyBaseRoles.TEACHER && usingUser.store.current.roles.some(r => r.role_key === HtySuperRoles.SUPERVISOR)) {
subsidiaries.value = usingSupervisor.store.subsidiaries;
@@ -679,19 +709,31 @@ export default defineComponent({
}
if (subsidiaries.value.length) {
let { start_date, end_date } = state;
await query_for_subsidiaries(start_date, end_date, subsidiaries.value.map(x => x.to_user_id))
await query_repeats_for_subsidiaries(start_date, end_date, subsidiaries.value.map(x => x.to_user_id))
const htyIds = subsidiaries.value.map(x => x.to_user_id);
return Promise.all([
query_for_subsidiaries(start_date, end_date, htyIds),
query_repeats_for_subsidiaries(start_date, end_date, htyIds),
]);
}
}
}
const search = async () => {
const search = async (force = false) => {
let { start_date, end_date } = state;
await query(start_date, end_date)
await query_repeats(start_date, end_date)
const key = `${start_date}|${end_date}|${usingUser.store.currentRole}`;
await searchForSubsidiaries();
// 相同范围且非强制刷新 → 跳过 API,仅重绘
if (!force && lastQueryKey.value === key) {
draw();
return;
}
await Promise.all([
query(start_date, end_date),
query_repeats(start_date, end_date),
searchForSubsidiaries(start_date, end_date),
]);
lastQueryKey.value = key;
draw();
}
@@ -745,37 +787,14 @@ export default defineComponent({
const options: CalendarOptions = {
locale: zhLocale,
// themeSystem: 'bootstrap5',
customButtons: {
current: {
text: '本周',
click: function() {
calendar.value.getApi().gotoDate(new Date())
}
},
createClazz: {
text: '创建排课',
click: function() {
if (!is_teacher.value) return;
const now = new Date();
store.current.start_from = formatDate(now, DateFormatter.DateTimeSave);
now.setMinutes(now.getMinutes() + 45);
store.current.end_by = formatDate(now, DateFormatter.DateTimeSave);
state.editing = true;
state.readonly = false;
state.title = "新增排课"
}
}
},
headerToolbar: {
left: 'prev,current,next',
center: 'title',
right: 'createClazz'
},
headerToolbar: false,
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 +828,12 @@ export default defineComponent({
view(event.id)
},
datesSet: function({start, end}) {
// 矩阵视图有自己的日期范围和数据加载逻辑
if (viewMode.value === 'matrix') return;
// 更新日历日期范围显示
const endDate = new Date(end);
endDate.setDate(endDate.getDate() - 1);
calendarDateRange.value = `${formatDate(start, DateFormatter.Date)} ~ ${formatDate(endDate, DateFormatter.Date)}`;
if (store.hanging) {
store.hanging = false;
let {state: cachedState} = getKey('clazz_state');
@@ -855,7 +880,8 @@ export default defineComponent({
if (await createInstance(store.current.instance)) {
state.editing = false;
store.current = {} as Clazz;
await search()
// 数据已变更,强制刷新
await search(true)
return;
}
}
@@ -863,11 +889,13 @@ export default defineComponent({
if (await createOrUpdate()) {
state.editing = false;
store.current = {} as Clazz;
await search()
// 数据已变更,强制刷新
await search(true)
}
}
const freeze = () => {
if (!calendar.value) return;
store.hanging = true;
let date = calendar.value.getApi().getDate();
setKey('clazz_state', {state, date})
@@ -982,7 +1010,8 @@ export default defineComponent({
}).then(async () => {
if (await removeCurrent(is_root)) {
cancel();
await search();
// 数据已变更,强制刷新
await search(true);
}
})
}
@@ -1080,6 +1109,7 @@ export default defineComponent({
isLandscape, matrixWeekStart, matrixWeekEnd, matrixWeekRange, matrixWeekDays,
matrixGoPrev, matrixGoNext, matrixGoToday,
onMatrixCellClick, onMatrixEventClick,
weekRange, calendarDateRange, navGoPrev, navGoToday, navGoNext,
}
}
})
@@ -1095,13 +1125,15 @@ export default defineComponent({
height: 100%;
min-height: 0;
:deep(.fc-toolbar-title) {
font-size: 1.25em;
}
:deep(.fc) {
height: 100%;
}
:deep(.fc-scroller) {
overflow-y: auto !important;
-webkit-overflow-scrolling: touch;
}
}
.calendar-wrapper {
@@ -1127,6 +1159,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 +1306,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 +1380,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 {
@@ -1350,7 +1402,6 @@ export default defineComponent({
font-size: 0.24rem;
white-space: nowrap;
}
}
&__orient {
@@ -1397,8 +1448,7 @@ export default defineComponent({
flex-shrink: 0;
min-height: var(--landscape-toolbar-height);
box-sizing: border-box;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
display: flex;
align-items: center;
gap: 0.08rem;
padding: 0.08rem 0.12rem;
@@ -1406,29 +1456,6 @@ export default defineComponent({
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;
@@ -1473,7 +1500,7 @@ export default defineComponent({
.landscape-stage-viewport--matrix {
flex: 1;
min-height: 0;
overflow: auto;
overflow: hidden;
}
.landscape-stage {
+1
View File
@@ -98,6 +98,7 @@ export default defineComponent({
const usingUser = useUser();
const {store, query, reset} = useDaka();
console.debug('[daka] setup, currentRole:', usingUser.store.currentRole);
const state = reactive({
keyword: '',
+1 -1
View File
@@ -159,7 +159,7 @@ export default defineComponent({
if (role === HtyBaseRoles.TEACHER) return '/teacher/home'
if (role === HtySuperRoles.ADMIN) return '/admin/teachers'
if (role === HtySuperRoles.TESTER) return '/tester'
if (role === HtySuperRoles.SUPERVISOR) return '/supervisor/subsidiaries'
if (role === HtySuperRoles.SUPERVISOR) return '/teacher/home'
return '/guest/profile'
}
+5 -2
View File
@@ -8,6 +8,7 @@ import {
Daka,
DakaScope,
HtyBaseRoles,
HtySuperRoles,
HtyRoles,
Clazz,
JihuaQueryParam,
@@ -82,7 +83,8 @@ export default function useDaka() {
async function query(params: JihuaQueryParam): Promise<boolean> {
let {current: {hty_id}, currentRole} = usingUser.store;
if (currentRole === HtyBaseRoles.TEACHER) {
console.debug('[daka query] currentRole:', currentRole, 'hty_id:', hty_id);
if (currentRole === HtyBaseRoles.TEACHER || currentRole === HtySuperRoles.SUPERVISOR) {
params.teacher_id = hty_id;
params.scope = DakaScope.ALL;
} else if (currentRole === HtyBaseRoles.STUDENT) {
@@ -92,10 +94,11 @@ export default function useDaka() {
params.teacher_id = teacherStore.currentTeacherId;
}
}
console.debug('[daka query] params after role setup:', params);
store.query_cache = params;
load_start()
const {r, d, e} = await request({url: '/api/v1/ws/find_dakas_with_sections_by_user_id', params});
load_done()
console.debug('[daka query] response:', {r, d, e});
if (r) {
if (params.page === 1) {
store.list = [];
+7 -1
View File
@@ -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();