feat(ui): 补齐课程提醒进度并内嵌编辑器与重构后台导航
This commit is contained in:
1 parent
3379eb975c
commit
536379036a
34 files changed
+1559
-455
No files matched your search
@@ -182,6 +182,29 @@ LessonReminderTracker 只读取服务端已解析的当天 ScheduleDto 投影,
|
|||||||
课前 60 秒开始:3 秒强调遮罩后显示实时秒数和课程时间,持续到上课;上课遮罩 5 秒后退出。
|
课前 60 秒开始:3 秒强调遮罩后显示实时秒数和课程时间,持续到上课;上课遮罩 5 秒后退出。
|
||||||
下课先提示 2 秒,再预告下节课程 10 秒;当天最后一课下课只提示 5 秒。不补弹已经过去的上下课事件。
|
下课先提示 2 秒,再预告下节课程 10 秒;当天最后一课下课只提示 5 秒。不补弹已经过去的上下课事件。
|
||||||
刷新同一课表不会重置遮罩,临时换课立即更新名称,移除课程或退出账号会取消提醒。
|
刷新同一课表不会重置遮罩,临时换课立即更新名称,移除课程或退出账号会取消提醒。
|
||||||
提醒是独立本机状态,不写入教师通知、未读计数或通知队列;已经打开的通知详情和紧急提示优先展示。
|
提醒是独立本机状态,不写入教师通知、未读计数或通知队列;用户主动打开的通知详情和紧急提示优先展示,自动补弹的普通通知不再挡住上下课提醒。
|
||||||
经典与自定义两种布局使用同一个 LessonReminderContent;原生窗口仍为固定画布,自定义岛用自然尺寸缩放适配。
|
经典与自定义两种布局使用同一个 LessonReminderContent;原生窗口仍为固定画布,自定义岛用自然尺寸缩放适配。
|
||||||
本次只移植课表可见提醒和动画,不自动启用语音与音效。
|
本次只移植课表可见提醒和动画,不自动启用语音与音效。
|
||||||
|
|
||||||
|
## 课程进度、即将下课与主窗口编辑(本批补齐)
|
||||||
|
|
||||||
|
- 对照上游 `LessonControlExpanded.axaml` 的 `LeftSeconds` 和 `ProgressPercent`,课程组件增加持续的本节进度条与距下课时间,课间自动显示下一课;起止时间保持在课程名右侧,按秒更新。
|
||||||
|
- 倒计时组件支持自动上下课、本节下课、下一节课、放学四种目标,按当天课表排序,排除其它日期。已有配置缺少新字段时自动补齐。
|
||||||
|
- 默认最后 60 秒显示即将下课倒数;经典岛在上课遮罩后持续显示本节课程、剩余时间与进度,自定义岛平时由组件显示进度,时间点提醒接管同一个岛体。
|
||||||
|
- 设置→提醒提供课前、上课、即将下课、下课独立开关,以及经典岛课中进度开关;课前提前 5–1800 秒,下课倒数提前 5–600 秒,保存后即刻生效。
|
||||||
|
- 自定义编辑器移到主窗口 `/settings/island/editor`;设置和兼容 IPC 入口都导航到主窗口,浮岛不再被扩大成全屏编辑窗口。主窗口保留侧栏,画布按内容与可用空间自动缩放,支持触摸跨行拖动、容器和独立常态/展开内容。
|
||||||
|
- 编辑使用独立预览,不上报桌面岛尺寸;配置保存按顺序执行。关闭编辑返回灵动岛设置。
|
||||||
|
|
||||||
|
## 管理后台界面与二级菜单
|
||||||
|
|
||||||
|
按用户确认,本批“重写后端”指 Web 管理后台界面与导航。参考 art-design-pro 的 sidebar-menu、breadcrumb、header-bar,重写工作区框架,服务端接口、鉴权和既有业务页面保留。
|
||||||
|
|
||||||
|
- 工作台、教学事务、班级与人员、设备运维、系统管理五个一级分组;原有全部业务入口位于二级菜单。
|
||||||
|
- 菜单直接读取路由的标题、图标、角色与生效权限;空分组隐藏,搜索结果和页签也按相同权限过滤,深链继续经过路由守卫。
|
||||||
|
- 增加面包屑、最多 20 个可关闭页签、关闭其它页签、Ctrl+K 功能搜索;页签保留该页面的完整查询参数。
|
||||||
|
- 统一浅/深主题下的侧栏、顶栏、卡片、表格和页面留白;仪表盘增加真实账号欢迎区与按权限显示的快捷入口。手机端使用同一棵二级菜单的抽屉。
|
||||||
|
- 回归使用独立临时 SQLite、临时教师和班级;不读取或改写真实班级业务数据。
|
||||||
|
|
||||||
|
### 老师消息阅读背景
|
||||||
|
|
||||||
|
通知、作业、叫人内容展示时,两种布局统一使用 `#202020` 不透明背景,原生窗口透明度临时为 1,玻璃风格和用户设置的透明度不会降低消息可读性。常驻组件、空闲展开和课程提醒仍按原有透明度显示,收起消息后恢复本机透明度偏好。
|
||||||
@@ -1604,3 +1604,9 @@ makensis 临时目录、ClassIsland 路径与日志编码等)。这里只列
|
|||||||
迁移选择同一时段 ID 最小的行,全部原行仍保留;旧数据未记录的群组、临时层和时间点名称无法恢复。
|
迁移选择同一时段 ID 最小的行,全部原行仍保留;旧数据未记录的群组、临时层和时间点名称无法恢复。
|
||||||
|
|
||||||
实施选择、问题根因与实测结果见 [实施记录](dev/20261017-implementation.md)。
|
实施选择、问题根因与实测结果见 [实施记录](dev/20261017-implementation.md)。
|
||||||
|
|
||||||
|
## 2026-10-17 课程状态与管理后台导航
|
||||||
|
|
||||||
|
课程右侧信息与本节进度参考 [LessonControlExpanded](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland.Core/Controls/LessonsControls/LessonControlExpanded.axaml)。新增课中距下课、末段秒数倒数及独立提醒设置;主窗口中的自定义编辑页保留组件拖动与方案管理。
|
||||||
|
|
||||||
|
管理后台结构参考 [art-design-pro](https://github.com/Daymychen/art-design-pro) 的 `art-sidebar-menu`、`SidebarSubmenu`、`art-breadcrumb` 与 `art-header-bar`;采用 Vue 3 / Element Plus 原有技术栈,重新实现分组菜单、面包屑、页签和搜索,业务接口和权限校验保持原有入口。
|
||||||
@@ -63,7 +63,9 @@ const fonts = {
|
|||||||
<label v-else-if="node.kind === 'countdown'" class="field">
|
<label v-else-if="node.kind === 'countdown'" class="field">
|
||||||
倒计时目标
|
倒计时目标
|
||||||
<select v-model="node.settings.target">
|
<select v-model="node.settings.target">
|
||||||
|
<option value="auto">自动:课中距下课,课间距上课</option>
|
||||||
<option value="next">下一节课</option>
|
<option value="next">下一节课</option>
|
||||||
|
<option value="end">本节下课</option>
|
||||||
<option value="school">放学</option>
|
<option value="school">放学</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
@@ -95,6 +97,10 @@ const fonts = {
|
|||||||
<span>显示课程时间</span>
|
<span>显示课程时间</span>
|
||||||
<input type="checkbox" v-model="node.settings.showPeriod" />
|
<input type="checkbox" v-model="node.settings.showPeriod" />
|
||||||
</label>
|
</label>
|
||||||
|
<label class="toggle-field">
|
||||||
|
<span>显示上课进度条</span>
|
||||||
|
<input type="checkbox" v-model="node.settings.showProgress" />
|
||||||
|
</label>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="['group', 'stack', 'slide', 'rolling'].includes(node.kind)">
|
<template v-else-if="['group', 'stack', 'slide', 'rolling'].includes(node.kind)">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, type CSSProperties } from 'vue';
|
import { computed, type CSSProperties } from 'vue';
|
||||||
import {
|
import {
|
||||||
ISLAND_COMPONENT_LABELS,
|
ISLAND_COMPONENT_LABELS,
|
||||||
|
resolveClassStatus,
|
||||||
type IslandComponentNode,
|
type IslandComponentNode,
|
||||||
type IslandState,
|
type IslandState,
|
||||||
type ScheduleDto,
|
type ScheduleDto,
|
||||||
@@ -23,16 +24,35 @@ function select(event: MouseEvent) {
|
|||||||
emit('select', props.node.id);
|
emit('select', props.node.id);
|
||||||
}
|
}
|
||||||
const container = computed(() => ['group', 'stack', 'slide', 'rolling'].includes(props.node.kind));
|
const container = computed(() => ['group', 'stack', 'slide', 'rolling'].includes(props.node.kind));
|
||||||
const current = computed(() => {
|
const today = computed(() =>
|
||||||
const d = new Date(props.now),
|
props.schedules
|
||||||
t = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
.filter((s) => s.dayOfWeek === (new Date(props.now).getDay() || 7))
|
||||||
return props.schedules.find((s) => s.startTime <= t && s.endTime > t);
|
.sort((a, b) => a.startTime.localeCompare(b.startTime)),
|
||||||
});
|
);
|
||||||
const next = computed(() => {
|
const status = computed(() => resolveClassStatus(today.value, new Date(props.now)));
|
||||||
const d = new Date(props.now),
|
const current = computed(() => today.value.find((s) => s.id === status.value.current?.scheduleId));
|
||||||
t = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
const next = computed(() => today.value.find((s) => s.id === status.value.next?.scheduleId));
|
||||||
return props.schedules.find((s) => s.startTime > t);
|
function timeAt(time: string) {
|
||||||
});
|
const d = new Date(props.now);
|
||||||
|
const [h, m] = time.split(':').map(Number);
|
||||||
|
d.setHours(h!, m!, 0, 0);
|
||||||
|
return d.getTime();
|
||||||
|
}
|
||||||
|
const elapsed = computed(() =>
|
||||||
|
current.value
|
||||||
|
? Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(
|
||||||
|
1,
|
||||||
|
(props.now - timeAt(current.value.startTime)) /
|
||||||
|
Math.max(1, timeAt(current.value.endTime) - timeAt(current.value.startTime)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: 0,
|
||||||
|
);
|
||||||
|
const remaining = computed(() =>
|
||||||
|
current.value ? Math.max(0, Math.ceil((timeAt(current.value.endTime) - props.now) / 1000)) : 0,
|
||||||
|
);
|
||||||
const hidden = computed(
|
const hidden = computed(
|
||||||
() =>
|
() =>
|
||||||
!props.editing &&
|
!props.editing &&
|
||||||
@@ -75,21 +95,27 @@ const content = computed(() => {
|
|||||||
.replaceAll('{course}', current.value?.course?.name || '暂无课程')
|
.replaceAll('{course}', current.value?.course?.name || '暂无课程')
|
||||||
: n.settings.text;
|
: n.settings.text;
|
||||||
if (n.kind === 'countdown') {
|
if (n.kind === 'countdown') {
|
||||||
const time = n.settings.target === 'school' ? props.schedules.at(-1)?.endTime : next.value?.startTime;
|
const ending = n.settings.target === 'end' || (n.settings.target === 'auto' && !!current.value);
|
||||||
|
const time =
|
||||||
|
n.settings.target === 'school'
|
||||||
|
? today.value.at(-1)?.endTime
|
||||||
|
: ending
|
||||||
|
? current.value?.endTime
|
||||||
|
: next.value?.startTime;
|
||||||
if (!time) return '今日课程已结束';
|
if (!time) return '今日课程已结束';
|
||||||
const [h, m] = time.split(':').map(Number);
|
const [h, m] = time.split(':').map(Number);
|
||||||
const end = new Date(d);
|
const end = new Date(d);
|
||||||
end.setHours(h!, m!, 0, 0);
|
end.setHours(h!, m!, 0, 0);
|
||||||
const left = Math.max(0, Math.ceil((end.getTime() - props.now) / 1000));
|
const left = Math.max(0, Math.ceil((end.getTime() - props.now) / 1000));
|
||||||
return `${n.settings.target === 'school' ? '距放学' : '距上课'} ${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
|
return `${n.settings.target === 'school' ? '距放学' : ending ? '距下课' : '距上课'} ${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
});
|
});
|
||||||
const lessons = computed(() =>
|
const lessons = computed(() =>
|
||||||
props.node.settings.display === 'today'
|
props.node.settings.display === 'today'
|
||||||
? props.schedules.slice(0, props.node.settings.count)
|
? today.value.slice(0, props.node.settings.count)
|
||||||
: [props.node.settings.display === 'next' ? next.value : current.value].filter((s): s is ScheduleDto =>
|
: [props.node.settings.display === 'next' ? next.value : current.value || next.value].filter(
|
||||||
Boolean(s),
|
(s): s is ScheduleDto => Boolean(s),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const style = computed<CSSProperties>(() => {
|
const style = computed<CSSProperties>(() => {
|
||||||
@@ -166,8 +192,22 @@ const style = computed<CSSProperties>(() => {
|
|||||||
<div v-for="s in lessons" :key="s.id" class="lesson-line">
|
<div v-for="s in lessons" :key="s.id" class="lesson-line">
|
||||||
<strong>{{ s.course?.name }}</strong>
|
<strong>{{ s.course?.name }}</strong>
|
||||||
<small v-if="node.settings.showPeriod" class="lesson-time">{{ s.startTime }}–{{ s.endTime }}</small>
|
<small v-if="node.settings.showPeriod" class="lesson-time">{{ s.startTime }}–{{ s.endTime }}</small>
|
||||||
|
<small v-if="node.settings.showProgress && current?.id === s.id" class="lesson-remaining">
|
||||||
|
距下课 {{ Math.floor(remaining / 60) }}:{{ String(remaining % 60).padStart(2, '0') }}
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="!lessons.length">暂无课程</span>
|
<span v-if="!lessons.length">暂无课程</span>
|
||||||
|
<div
|
||||||
|
v-if="current && node.settings.showProgress && node.settings.display !== 'next'"
|
||||||
|
class="lesson-progress"
|
||||||
|
role="progressbar"
|
||||||
|
:aria-valuenow="Math.round(elapsed * 100)"
|
||||||
|
:aria-valuemin="0"
|
||||||
|
:aria-valuemax="100"
|
||||||
|
:aria-label="`${current.course?.name}课程进度,距下课${remaining}秒`"
|
||||||
|
>
|
||||||
|
<i :style="{ transform: `scaleX(${elapsed})` }" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="['notification', 'homework', 'call'].includes(node.kind)">
|
<template v-else-if="['notification', 'homework', 'call'].includes(node.kind)">
|
||||||
<slot v-if="node.id === alertNodeId" name="alerts" />
|
<slot v-if="node.id === alertNodeId" name="alerts" />
|
||||||
@@ -197,7 +237,7 @@ const style = computed<CSSProperties>(() => {
|
|||||||
.countdown {
|
.countdown {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.schedule > div {
|
.schedule > .lesson-line {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -215,9 +255,29 @@ const style = computed<CSSProperties>(() => {
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.schedule > div + div {
|
.lesson-progress {
|
||||||
|
position: absolute;
|
||||||
|
inset: auto 4px 0;
|
||||||
|
height: 2px;
|
||||||
|
background: #ffffff24;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.lesson-progress i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--island-accent, #60a5fa);
|
||||||
|
transform-origin: left;
|
||||||
|
transition: transform 1s linear;
|
||||||
|
}
|
||||||
|
.schedule > .lesson-line + .lesson-line {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
.lesson-remaining {
|
||||||
|
font-size: var(--secondary, 14px);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
opacity: 0.8;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.separator > span {
|
.separator > span {
|
||||||
display: block;
|
display: block;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const props = defineProps<{
|
|||||||
editing: boolean;
|
editing: boolean;
|
||||||
appearance: IslandAppearance;
|
appearance: IslandAppearance;
|
||||||
schedules: ScheduleDto[];
|
schedules: ScheduleDto[];
|
||||||
|
embedded?: boolean;
|
||||||
}>();
|
}>();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
save: [settings: IslandLayoutSettings];
|
save: [settings: IslandLayoutSettings];
|
||||||
@@ -115,6 +116,10 @@ function switchScheme() {
|
|||||||
const now = ref(Date.now()),
|
const now = ref(Date.now()),
|
||||||
canvasScale = ref(1),
|
canvasScale = ref(1),
|
||||||
pan = ref({ x: 0, y: 0 });
|
pan = ref({ x: 0, y: 0 });
|
||||||
|
const canvas = ref<HTMLElement>();
|
||||||
|
const previewStage = ref<HTMLElement>();
|
||||||
|
const autoScale = ref(1);
|
||||||
|
const previewScale = computed(() => canvasScale.value * autoScale.value);
|
||||||
const shell = ref<HTMLElement>(),
|
const shell = ref<HTMLElement>(),
|
||||||
inner = ref<HTMLElement>();
|
inner = ref<HTMLElement>();
|
||||||
const viewport = ref({ width: 0, height: 0 });
|
const viewport = ref({ width: 0, height: 0 });
|
||||||
@@ -358,6 +363,21 @@ let observer: ResizeObserver | undefined,
|
|||||||
function measure() {
|
function measure() {
|
||||||
clearTimeout(measureTimer);
|
clearTimeout(measureTimer);
|
||||||
measureTimer = setTimeout(() => {
|
measureTimer = setTimeout(() => {
|
||||||
|
if (props.embedded && shell.value && canvas.value) {
|
||||||
|
const padding = getComputedStyle(canvas.value);
|
||||||
|
const availableWidth =
|
||||||
|
canvas.value.clientWidth - parseFloat(padding.paddingLeft) - parseFloat(padding.paddingRight);
|
||||||
|
const availableHeight =
|
||||||
|
canvas.value.clientHeight - parseFloat(padding.paddingTop) - parseFloat(padding.paddingBottom);
|
||||||
|
autoScale.value = Math.min(
|
||||||
|
1,
|
||||||
|
Math.max(80, availableWidth) /
|
||||||
|
Math.max(1, previewStage.value?.offsetWidth ?? shell.value.offsetWidth),
|
||||||
|
Math.max(100, availableHeight) /
|
||||||
|
Math.max(1, previewStage.value?.offsetHeight ?? shell.value.offsetHeight),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (inner.value) {
|
if (inner.value) {
|
||||||
// 内部组件的自然尺寸决定整个岛体;提醒不再拥有另一层岛壳。
|
// 内部组件的自然尺寸决定整个岛体;提醒不再拥有另一层岛壳。
|
||||||
const rect = inner.value.getBoundingClientRect(),
|
const rect = inner.value.getBoundingClientRect(),
|
||||||
@@ -387,6 +407,8 @@ onMounted(() => {
|
|||||||
clock = setInterval(() => (now.value = Date.now()), 1000);
|
clock = setInterval(() => (now.value = Date.now()), 1000);
|
||||||
observer = new ResizeObserver(measure);
|
observer = new ResizeObserver(measure);
|
||||||
if (inner.value) observer.observe(inner.value);
|
if (inner.value) observer.observe(inner.value);
|
||||||
|
if (canvas.value) observer.observe(canvas.value);
|
||||||
|
if (previewStage.value) observer.observe(previewStage.value);
|
||||||
stopViewport = window.island?.onViewport((v) => (viewport.value = v));
|
stopViewport = window.island?.onViewport((v) => (viewport.value = v));
|
||||||
measure();
|
measure();
|
||||||
});
|
});
|
||||||
@@ -400,6 +422,7 @@ onUnmounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
:class="editing ? 'layout-editor' : 'custom-window'"
|
:class="editing ? 'layout-editor' : 'custom-window'"
|
||||||
|
:data-embedded="embedded || undefined"
|
||||||
@pointerdown.capture="onPointerDown"
|
@pointerdown.capture="onPointerDown"
|
||||||
@pointermove="panMove"
|
@pointermove="panMove"
|
||||||
@pointerup="
|
@pointerup="
|
||||||
@@ -482,7 +505,7 @@ onUnmounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<main :class="{ canvas: editing }" @pointerdown="panStart">
|
<main ref="canvas" :class="{ canvas: editing }" @pointerdown="panStart">
|
||||||
<div v-if="editing" class="canvas-toolbar">
|
<div v-if="editing" class="canvas-toolbar">
|
||||||
<nav class="surface-tabs" aria-label="编辑自定义灵动岛内容">
|
<nav class="surface-tabs" aria-label="编辑自定义灵动岛内容">
|
||||||
<button :class="{ active: editingSurface === 'compact' }" @click="setSurface('compact')">
|
<button :class="{ active: editingSurface === 'compact' }" @click="setSurface('compact')">
|
||||||
@@ -513,8 +536,9 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
ref="previewStage"
|
||||||
class="preview-stage"
|
class="preview-stage"
|
||||||
:style="editing ? { transform: `translate(${pan.x}px,${pan.y}px) scale(${canvasScale})` } : {}"
|
:style="editing ? { transform: `translate(${pan.x}px,${pan.y}px) scale(${previewScale})` } : {}"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref="shell"
|
ref="shell"
|
||||||
|
|||||||
@@ -254,8 +254,8 @@ function measure() {
|
|||||||
}
|
}
|
||||||
function resizeCanvas(width = innerWidth, height = innerHeight) {
|
function resizeCanvas(width = innerWidth, height = innerHeight) {
|
||||||
available.value = {
|
available.value = {
|
||||||
width: Math.max(40, width - 2 * ISLAND_SHADOW_PAD - 4),
|
width: Math.max(40, Math.min(width, innerWidth) - 2 * ISLAND_SHADOW_PAD - 4),
|
||||||
height: Math.max(40, height - 2 * ISLAND_SHADOW_PAD - 4),
|
height: Math.max(40, Math.min(height, innerHeight) - 2 * ISLAND_SHADOW_PAD - 4),
|
||||||
};
|
};
|
||||||
measure();
|
measure();
|
||||||
}
|
}
|
||||||
@@ -263,6 +263,7 @@ const resize = () => resizeCanvas();
|
|||||||
let stopViewport: (() => void) | undefined;
|
let stopViewport: (() => void) | undefined;
|
||||||
function visibility() {
|
function visibility() {
|
||||||
visible.value = !document.hidden;
|
visible.value = !document.hidden;
|
||||||
|
resize();
|
||||||
void nextTick(reportHit);
|
void nextTick(reportHit);
|
||||||
}
|
}
|
||||||
// 不深度监听整份状态;只在目标内容/几何变化时测量,时钟每秒只改文本。
|
// 不深度监听整份状态;只在目标内容/几何变化时测量,时钟每秒只改文本。
|
||||||
@@ -285,7 +286,11 @@ watch(
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
resize();
|
resize();
|
||||||
stopViewport = window.island?.onViewport((v) => resizeCanvas(v.width, v.height));
|
stopViewport = window.island?.onViewport((v) => resizeCanvas(v.width, v.height));
|
||||||
observer = new ResizeObserver(measure);
|
observer = new ResizeObserver((entries) => {
|
||||||
|
if (entries.some((entry) => entry.target === document.documentElement)) resize();
|
||||||
|
else measure();
|
||||||
|
});
|
||||||
|
observer.observe(document.documentElement);
|
||||||
if (compact.value) observer.observe(compact.value);
|
if (compact.value) observer.observe(compact.value);
|
||||||
if (details.value) observer.observe(details.value);
|
if (details.value) observer.observe(details.value);
|
||||||
if (lessonSource.value) observer.observe(lessonSource.value);
|
if (lessonSource.value) observer.observe(lessonSource.value);
|
||||||
@@ -327,6 +332,7 @@ onUnmounted(() => {
|
|||||||
call: state.active?.kind === 'call',
|
call: state.active?.kind === 'call',
|
||||||
'resident-separated': scheme.separated && !expanded && !lesson,
|
'resident-separated': scheme.separated && !expanded && !lesson,
|
||||||
'resident-lesson': !!lesson,
|
'resident-lesson': !!lesson,
|
||||||
|
'message-surface': expanded && messages.length > 0 && !lesson,
|
||||||
}"
|
}"
|
||||||
:style="style"
|
:style="style"
|
||||||
:role="expanded ? 'region' : 'button'"
|
:role="expanded ? 'region' : 'button'"
|
||||||
|
|||||||
@@ -389,6 +389,7 @@ const islandClass = computed(() => ({
|
|||||||
'hidden-placeholder':
|
'hidden-placeholder':
|
||||||
mode.value === 'hidden' && !appearance.value.idleSliver && !state.value.lessonReminder,
|
mode.value === 'hidden' && !appearance.value.idleSliver && !state.value.lessonReminder,
|
||||||
'ci-lesson-card': !!state.value.lessonReminder,
|
'ci-lesson-card': !!state.value.lessonReminder,
|
||||||
|
'message-surface': !!state.value.active && mode.value !== 'hidden' && !state.value.lessonReminder,
|
||||||
sliver: mode.value === 'hidden' && appearance.value.idleSliver,
|
sliver: mode.value === 'hidden' && appearance.value.idleSliver,
|
||||||
urgent: isUrgent.value,
|
urgent: isUrgent.value,
|
||||||
call: isCall.value,
|
call: isCall.value,
|
||||||
|
|||||||
@@ -1,24 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { nextTick, onMounted, onUnmounted, ref, shallowRef } from 'vue';
|
import { nextTick, onMounted, onUnmounted, ref } from 'vue';
|
||||||
import {
|
import {
|
||||||
defaultIslandLayouts,
|
defaultIslandLayouts,
|
||||||
DEFAULT_ISLAND_APPEARANCE,
|
DEFAULT_ISLAND_APPEARANCE,
|
||||||
type IslandAppearance,
|
type IslandAppearance,
|
||||||
type IslandLayoutSettings,
|
|
||||||
type IslandState,
|
type IslandState,
|
||||||
type ScheduleDto,
|
type ScheduleDto,
|
||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
import FixedIsland from './IslandApp.vue';
|
import FixedIsland from './IslandApp.vue';
|
||||||
import CustomIsland from './CustomIsland.vue';
|
import './custom-island.css';
|
||||||
import CustomIslandRuntime from './CustomIslandRuntime.vue';
|
import CustomIslandRuntime from './CustomIslandRuntime.vue';
|
||||||
const settings = ref(defaultIslandLayouts()),
|
const settings = ref(defaultIslandLayouts()),
|
||||||
editing = ref(false),
|
|
||||||
ready = ref(false);
|
ready = ref(false);
|
||||||
const schedules = ref<ScheduleDto[]>([]);
|
const schedules = ref<ScheduleDto[]>([]);
|
||||||
const appearance = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
|
const appearance = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
|
||||||
const nativeVisible = ref(false);
|
const nativeVisible = ref(false);
|
||||||
const alertHost = shallowRef<HTMLElement | null>(null);
|
const resident = () => settings.value.layout === 'custom';
|
||||||
const resident = () => settings.value.layout === 'custom' && !editing.value;
|
|
||||||
const state = ref<IslandState>({
|
const state = ref<IslandState>({
|
||||||
mode: 'hidden',
|
mode: 'hidden',
|
||||||
active: null,
|
active: null,
|
||||||
@@ -30,13 +27,6 @@ const state = ref<IslandState>({
|
|||||||
maxCardHeight: 1024,
|
maxCardHeight: 1024,
|
||||||
updatedAt: 0,
|
updatedAt: 0,
|
||||||
});
|
});
|
||||||
async function save(value: IslandLayoutSettings) {
|
|
||||||
settings.value = await window.island!.saveLayout(JSON.parse(JSON.stringify(value)));
|
|
||||||
}
|
|
||||||
async function done() {
|
|
||||||
editing.value = false;
|
|
||||||
await window.island?.setEditing(false);
|
|
||||||
}
|
|
||||||
let heartbeat: ReturnType<typeof setInterval>;
|
let heartbeat: ReturnType<typeof setInterval>;
|
||||||
onUnmounted(() => clearInterval(heartbeat));
|
onUnmounted(() => clearInterval(heartbeat));
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -44,7 +34,6 @@ onMounted(async () => {
|
|||||||
const bridge = window.island;
|
const bridge = window.island;
|
||||||
if (bridge) {
|
if (bridge) {
|
||||||
bridge.onLayout((value) => (settings.value = value));
|
bridge.onLayout((value) => (settings.value = value));
|
||||||
bridge.onEditing((value) => (editing.value = value));
|
|
||||||
bridge.onSchedules((value) => (schedules.value = value));
|
bridge.onSchedules((value) => (schedules.value = value));
|
||||||
bridge.onState((value) => (state.value = value));
|
bridge.onState((value) => (state.value = value));
|
||||||
bridge.onAppearance((value) => (appearance.value = value));
|
bridge.onAppearance((value) => (appearance.value = value));
|
||||||
@@ -54,7 +43,6 @@ onMounted(async () => {
|
|||||||
settings.value = await bridge.getLayout();
|
settings.value = await bridge.getLayout();
|
||||||
schedules.value = await bridge.getSchedules();
|
schedules.value = await bridge.getSchedules();
|
||||||
state.value = await bridge.getState();
|
state.value = await bridge.getState();
|
||||||
editing.value = await bridge.getEditing();
|
|
||||||
}
|
}
|
||||||
await nextTick();
|
await nextTick();
|
||||||
ready.value = true;
|
ready.value = true;
|
||||||
@@ -69,26 +57,5 @@ onMounted(async () => {
|
|||||||
:appearance="appearance"
|
:appearance="appearance"
|
||||||
:native-visible="nativeVisible"
|
:native-visible="nativeVisible"
|
||||||
/>
|
/>
|
||||||
<CustomIsland
|
<FixedIsland v-if="ready" v-show="!resident()" :suspended="resident()" />
|
||||||
v-else-if="settings.layout === 'custom'"
|
|
||||||
:settings="settings"
|
|
||||||
:state="state"
|
|
||||||
:editing="editing"
|
|
||||||
:appearance="appearance"
|
|
||||||
:schedules="schedules"
|
|
||||||
@save="save"
|
|
||||||
@done="done"
|
|
||||||
@host="alertHost = $event"
|
|
||||||
/>
|
|
||||||
<Teleport
|
|
||||||
v-if="ready"
|
|
||||||
:disabled="resident() || settings.layout === 'fixed' || !alertHost"
|
|
||||||
:to="alertHost || 'body'"
|
|
||||||
>
|
|
||||||
<FixedIsland
|
|
||||||
v-show="!resident() && (settings.layout === 'fixed' || !!alertHost)"
|
|
||||||
:embedded="settings.layout === 'custom'"
|
|
||||||
:suspended="resident()"
|
|
||||||
/>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
</template>
|
||||||
@@ -12,17 +12,39 @@ const root = ref<HTMLElement>();
|
|||||||
const width = ref(480);
|
const width = ref(480);
|
||||||
const mask = computed(() => props.now < props.reminder.maskUntil);
|
const mask = computed(() => props.now < props.reminder.maskUntil);
|
||||||
const title = computed(
|
const title = computed(
|
||||||
() => ({ prepare: '即将上课', 'on-class': '上课', break: '课间休息' })[props.reminder.kind],
|
() =>
|
||||||
|
({
|
||||||
|
prepare: '即将上课',
|
||||||
|
'on-class': '上课',
|
||||||
|
break: '课间休息',
|
||||||
|
ending: '即将下课',
|
||||||
|
'in-class': '正在上课',
|
||||||
|
})[props.reminder.kind],
|
||||||
);
|
);
|
||||||
const leftSeconds = computed(() =>
|
const leftSeconds = computed(() =>
|
||||||
Math.max(0, Math.ceil((props.reminder.lessonStartsAt - props.now) / 1000)),
|
Math.max(
|
||||||
|
0,
|
||||||
|
Math.ceil(
|
||||||
|
((props.reminder.kind === 'ending' || props.reminder.kind === 'in-class'
|
||||||
|
? props.reminder.lessonEndsAt
|
||||||
|
: props.reminder.lessonStartsAt)! -
|
||||||
|
props.now) /
|
||||||
|
1000,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const progress = computed(() =>
|
const progress = computed(() =>
|
||||||
Math.max(
|
Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(
|
Math.min(
|
||||||
1,
|
1,
|
||||||
(props.reminder.endsAt - props.now) / Math.max(1, props.reminder.endsAt - props.reminder.startedAt),
|
props.reminder.kind === 'in-class' || props.reminder.kind === 'ending'
|
||||||
|
? (props.now - props.reminder.lessonStartsAt) /
|
||||||
|
Math.max(
|
||||||
|
1,
|
||||||
|
(props.reminder.lessonEndsAt ?? props.reminder.endsAt) - props.reminder.lessonStartsAt,
|
||||||
|
)
|
||||||
|
: (props.reminder.endsAt - props.now) / Math.max(1, props.reminder.endsAt - props.reminder.startedAt),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -107,19 +129,36 @@ onUnmounted(() => {
|
|||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
<div class="ci-lesson-info">
|
<div class="ci-lesson-info">
|
||||||
<span v-if="reminder.kind === 'prepare'" class="ci-countdown">
|
<span
|
||||||
距上课还剩
|
v-if="reminder.kind === 'prepare' || reminder.kind === 'ending' || reminder.kind === 'in-class'"
|
||||||
|
class="ci-countdown"
|
||||||
|
>
|
||||||
|
{{ reminder.kind === 'prepare' ? '距上课还剩' : '距下课还剩' }}
|
||||||
<strong>{{ leftSeconds }}</strong>
|
<strong>{{ leftSeconds }}</strong>
|
||||||
秒
|
秒
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="ci-countdown">课间休息</span>
|
<span v-else class="ci-countdown">课间休息</span>
|
||||||
<span class="ci-lesson-course">
|
<span class="ci-lesson-course">
|
||||||
<span>下节课是:</span>
|
<span>
|
||||||
|
{{
|
||||||
|
reminder.kind === 'ending' || reminder.kind === 'on-class' || reminder.kind === 'in-class'
|
||||||
|
? '本节课:'
|
||||||
|
: '下节课是:'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
<strong>{{ course }}</strong>
|
<strong>{{ course }}</strong>
|
||||||
<small>{{ time }}</small>
|
<small>{{ time }}</small>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ci-lesson-progress" :style="{ transform: `scaleX(${progress})` }" />
|
<div
|
||||||
|
class="ci-lesson-progress"
|
||||||
|
role="progressbar"
|
||||||
|
:aria-valuenow="Math.round(progress * 100)"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
aria-label="课程与提醒进度"
|
||||||
|
:style="{ transform: `scaleX(${progress})` }"
|
||||||
|
/>
|
||||||
<svg class="ci-mask" :viewBox="`0 0 ${width} 40`" preserveAspectRatio="none" aria-hidden="true">
|
<svg class="ci-mask" :viewBox="`0 0 ${width} 40`" preserveAspectRatio="none" aria-hidden="true">
|
||||||
<polygon v-for="(points, i) in polygons" :key="i" :points="points" />
|
<polygon v-for="(points, i) in polygons" :key="i" :points="points" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ body {
|
|||||||
--wn-bg: #0b0b0f;
|
--wn-bg: #0b0b0f;
|
||||||
--wn-border: rgba(255, 255, 255, 0.14);
|
--wn-border: rgba(255, 255, 255, 0.14);
|
||||||
}
|
}
|
||||||
|
/* 阅读老师消息时使用实色底,玻璃风格也不透出桌面。 */
|
||||||
|
.island-card.message-surface {
|
||||||
|
--wn-bg: #202020;
|
||||||
|
}
|
||||||
|
|
||||||
.island-card.hidden-placeholder {
|
.island-card.hidden-placeholder {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
|
|||||||
@@ -250,3 +250,6 @@
|
|||||||
fill: transparent;
|
fill: transparent;
|
||||||
stroke: none;
|
stroke: none;
|
||||||
}
|
}
|
||||||
|
.resident-window .resident-card.message-surface {
|
||||||
|
--wn-bg: #202020;
|
||||||
|
}
|
||||||
@@ -227,6 +227,13 @@ if (!gotLock) {
|
|||||||
mainWindow.show();
|
mainWindow.show();
|
||||||
mainWindow.focus();
|
mainWindow.focus();
|
||||||
});
|
});
|
||||||
|
island.setEditLayoutHandler(() => {
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||||
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||||
|
mainWindow.show();
|
||||||
|
mainWindow.focus();
|
||||||
|
mainWindow.webContents.send('island:open-editor');
|
||||||
|
});
|
||||||
// 灵动岛点"标为已读":转交主窗口渲染进程调用接口并刷新通知中心
|
// 灵动岛点"标为已读":转交主窗口渲染进程调用接口并刷新通知中心
|
||||||
island.setMarkReadHandler((id) => {
|
island.setMarkReadHandler((id) => {
|
||||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ class IslandController {
|
|||||||
!this.previewId &&
|
!this.previewId &&
|
||||||
!(
|
!(
|
||||||
this.state.active &&
|
this.state.active &&
|
||||||
(this.state.mode === 'expanded' ||
|
(this.explicitShowId === this.state.active.id ||
|
||||||
(this.state.active.priority === 'URGENT' && this.state.mode !== 'hidden'))
|
(this.state.active.priority === 'URGENT' && this.state.mode !== 'hidden'))
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -183,7 +183,13 @@ class IslandController {
|
|||||||
this.refreshLessonReminder();
|
this.refreshLessonReminder();
|
||||||
}
|
}
|
||||||
private refreshLessonReminder(): void {
|
private refreshLessonReminder(): void {
|
||||||
const next = this.lessonTracker.update(this.schedules, new Date(this.lessonTestTime ?? Date.now()));
|
let next = this.lessonTracker.update(
|
||||||
|
this.schedules,
|
||||||
|
new Date(this.lessonTestTime ?? Date.now()),
|
||||||
|
getConfig().islandLayout.lessonReminders,
|
||||||
|
);
|
||||||
|
// 自定义布局由课程组件持续显示进度,时间点提醒才临时接管岛体。
|
||||||
|
if (this.customLayout && next?.kind === 'in-class') next = null;
|
||||||
if (JSON.stringify(next) === JSON.stringify(this.lessonCue)) return;
|
if (JSON.stringify(next) === JSON.stringify(this.lessonCue)) return;
|
||||||
this.lessonCue = next;
|
this.lessonCue = next;
|
||||||
// 触摸窗口先放得下新横条,再下发内容;混合宽高变化也只能增大画布。
|
// 触摸窗口先放得下新横条,再下发内容;混合宽高变化也只能增大画布。
|
||||||
@@ -209,6 +215,15 @@ class IslandController {
|
|||||||
private boundsFrame: number | null = null;
|
private boundsFrame: number | null = null;
|
||||||
/** 外观设置(设置页可调,主进程持久化) */
|
/** 外观设置(设置页可调,主进程持久化) */
|
||||||
private appearance: IslandAppearance = { ...DEFAULT_ISLAND_APPEARANCE };
|
private appearance: IslandAppearance = { ...DEFAULT_ISLAND_APPEARANCE };
|
||||||
|
/** 阅读消息时同时关闭原生窗口透明度,防止实色 CSS 仍被窗口整体淡化。 */
|
||||||
|
private get displayOpacity(): number {
|
||||||
|
const readingMessage =
|
||||||
|
this.state.active &&
|
||||||
|
this.state.mode !== 'hidden' &&
|
||||||
|
(!this.customLayout || this.state.mode === 'expanded') &&
|
||||||
|
!this.lessonShowing;
|
||||||
|
return readingMessage ? 1 : this.appearance.opacity;
|
||||||
|
}
|
||||||
/** 当前是否接收鼠标(固定大包围盒窗口默认穿透,由渲染进程按命中动态打开) */
|
/** 当前是否接收鼠标(固定大包围盒窗口默认穿透,由渲染进程按命中动态打开) */
|
||||||
/** 是否允许"失焦自动收起"(冒烟可临时关闭,避免焦点抖动干扰断言) */
|
/** 是否允许"失焦自动收起"(冒烟可临时关闭,避免焦点抖动干扰断言) */
|
||||||
private blurCollapseEnabled = true;
|
private blurCollapseEnabled = true;
|
||||||
@@ -289,6 +304,7 @@ class IslandController {
|
|||||||
return this.editingLayout;
|
return this.editingLayout;
|
||||||
}
|
}
|
||||||
syncCustomLayout(): void {
|
syncCustomLayout(): void {
|
||||||
|
this.refreshLessonReminder();
|
||||||
if (this.residentEnabled !== this.residentWasEnabled) {
|
if (this.residentEnabled !== this.residentWasEnabled) {
|
||||||
this.residentFaded = false;
|
this.residentFaded = false;
|
||||||
this.residentWasEnabled = this.residentEnabled;
|
this.residentWasEnabled = this.residentEnabled;
|
||||||
@@ -317,42 +333,12 @@ class IslandController {
|
|||||||
this.syncWindow();
|
this.syncWindow();
|
||||||
}, 320);
|
}, 320);
|
||||||
}
|
}
|
||||||
|
private editLayoutHandler: (() => void) | null = null;
|
||||||
|
setEditLayoutHandler(handler: () => void): void {
|
||||||
|
this.editLayoutHandler = handler;
|
||||||
|
}
|
||||||
editLayout(enabled = true): void {
|
editLayout(enabled = true): void {
|
||||||
if (enabled) this.residentFaded = false;
|
if (enabled) this.editLayoutHandler?.();
|
||||||
if (this.layoutSettleTimer) clearTimeout(this.layoutSettleTimer);
|
|
||||||
this.layoutSettleTimer = null;
|
|
||||||
this.settlingLayout = false;
|
|
||||||
this.editingLayout = enabled;
|
|
||||||
this.syncResidentShape();
|
|
||||||
if (enabled) saveConfig({ islandLayout: { ...getConfig().islandLayout, layout: 'custom' } });
|
|
||||||
this.win?.webContents.send('island:layout', getConfig().islandLayout);
|
|
||||||
this.win?.webContents.send('island:editing', enabled);
|
|
||||||
if (this.win && !this.win.isDestroyed()) {
|
|
||||||
this.win.hide();
|
|
||||||
if (enabled) {
|
|
||||||
const area = screen.getDisplayNearestPoint(this.win.getBounds()).workArea;
|
|
||||||
this.win.setBounds(area);
|
|
||||||
this.win.setOpacity(1);
|
|
||||||
if (!(process.env.ELECTRON_SMOKE_TEST === '1' && process.env.ELECTRON_SMOKE_MODE !== 'visual')) {
|
|
||||||
this.win.show();
|
|
||||||
this.win.focus();
|
|
||||||
}
|
|
||||||
this.setInteractive(true, '布局编辑');
|
|
||||||
} else {
|
|
||||||
const area = screen.getDisplayNearestPoint(this.win.getBounds()).workArea;
|
|
||||||
this.customSize = {
|
|
||||||
width: Math.max(40, Math.min(this.customDesiredSize.width, area.width - 2 * ISLAND_SHADOW_PAD)),
|
|
||||||
height: Math.max(24, Math.min(this.customDesiredSize.height, area.height - 2 * ISLAND_SHADOW_PAD)),
|
|
||||||
};
|
|
||||||
this.win.webContents.send('island:viewport', this.customSize);
|
|
||||||
this.applyWindowLayout();
|
|
||||||
if (this.customLayout) {
|
|
||||||
// 退出编辑先保持隐藏,等行工具栏移除、渲染器防抖测量和主进程防抖完成。
|
|
||||||
// 避免把编辑器的占位高度带回常态岛;收缩仍全部发生在不可见期间。
|
|
||||||
this.settleCustomLayout();
|
|
||||||
} else this.syncWindow();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
reportCustomSize(value: { width: number; height: number }): void {
|
reportCustomSize(value: { width: number; height: number }): void {
|
||||||
if (
|
if (
|
||||||
@@ -1393,7 +1379,7 @@ class IslandController {
|
|||||||
if (this.residentEnabled) this.applyFocusable();
|
if (this.residentEnabled) this.applyFocusable();
|
||||||
this.updateResidentPointer();
|
this.updateResidentPointer();
|
||||||
this.win.setOpacity(
|
this.win.setOpacity(
|
||||||
this.appearance.opacity * (this.residentFaded ? CLASSISLAND_ISLAND_METRICS.fadedOpacity : 1),
|
this.displayOpacity * (this.residentFaded ? CLASSISLAND_ISLAND_METRICS.fadedOpacity : 1),
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
!this.win.isVisible() &&
|
!this.win.isVisible() &&
|
||||||
@@ -1408,7 +1394,7 @@ class IslandController {
|
|||||||
if (this.lessonShowing) {
|
if (this.lessonShowing) {
|
||||||
this.cancelFade();
|
this.cancelFade();
|
||||||
if (!this.win.isVisible()) this.applyWindowLayout();
|
if (!this.win.isVisible()) this.applyWindowLayout();
|
||||||
this.win.setOpacity(this.appearance.opacity);
|
this.win.setOpacity(this.displayOpacity);
|
||||||
if (!(process.env.ELECTRON_SMOKE_TEST === '1' && process.env.ELECTRON_SMOKE_MODE !== 'visual'))
|
if (!(process.env.ELECTRON_SMOKE_TEST === '1' && process.env.ELECTRON_SMOKE_MODE !== 'visual'))
|
||||||
this.win.showInactive();
|
this.win.showInactive();
|
||||||
this.syncHitFromCursor();
|
this.syncHitFromCursor();
|
||||||
@@ -1441,7 +1427,7 @@ class IslandController {
|
|||||||
this.setInteractive(false, '隐藏态');
|
this.setInteractive(false, '隐藏态');
|
||||||
if (this.appearance.idleSliver) {
|
if (this.appearance.idleSliver) {
|
||||||
this.cancelFade();
|
this.cancelFade();
|
||||||
this.win.setOpacity(this.appearance.opacity);
|
this.win.setOpacity(this.displayOpacity);
|
||||||
if (!this.win.isVisible()) this.win.showInactive();
|
if (!this.win.isVisible()) this.win.showInactive();
|
||||||
// 细缝也要"可见即可点":按真实光标位置决定是否接收鼠标
|
// 细缝也要"可见即可点":按真实光标位置决定是否接收鼠标
|
||||||
this.syncHitFromCursor();
|
this.syncHitFromCursor();
|
||||||
@@ -1454,8 +1440,8 @@ class IslandController {
|
|||||||
// 岛应该可见:**必须**先取消可能还在跑的淡出 —— 否则那次淡出会继续把透明度降到 0
|
// 岛应该可见:**必须**先取消可能还在跑的淡出 —— 否则那次淡出会继续把透明度降到 0
|
||||||
// 并 hide() 掉窗口,造成"状态是胶囊/展开、窗口却被隐藏"的错位(岛看着在、点不动或直接消失)。
|
// 并 hide() 掉窗口,造成"状态是胶囊/展开、窗口却被隐藏"的错位(岛看着在、点不动或直接消失)。
|
||||||
this.cancelFade();
|
this.cancelFade();
|
||||||
if (this.win.getOpacity() !== this.appearance.opacity) {
|
if (this.win.getOpacity() !== this.displayOpacity) {
|
||||||
this.win.setOpacity(this.appearance.opacity);
|
this.win.setOpacity(this.displayOpacity);
|
||||||
}
|
}
|
||||||
if (!this.win.isVisible()) {
|
if (!this.win.isVisible()) {
|
||||||
// 窗口位置由固定包围盒决定(applyWindowLayout),这里只负责显示
|
// 窗口位置由固定包围盒决定(applyWindowLayout),这里只负责显示
|
||||||
@@ -1628,7 +1614,7 @@ class IslandController {
|
|||||||
|
|
||||||
private fadeIn(): void {
|
private fadeIn(): void {
|
||||||
if (!this.win || this.win.isDestroyed()) return;
|
if (!this.win || this.win.isDestroyed()) return;
|
||||||
const target = this.appearance.opacity;
|
const target = this.displayOpacity;
|
||||||
if (!this.appearance.animations) {
|
if (!this.appearance.animations) {
|
||||||
this.fadeToken += 1;
|
this.fadeToken += 1;
|
||||||
this.win.setOpacity(target);
|
this.win.setOpacity(target);
|
||||||
@@ -1721,7 +1707,7 @@ class IslandController {
|
|||||||
this.win.setAlwaysOnTop(this.appearance.alwaysOnTop, 'screen-saver');
|
this.win.setAlwaysOnTop(this.appearance.alwaysOnTop, 'screen-saver');
|
||||||
if (this.win.isVisible()) {
|
if (this.win.isVisible()) {
|
||||||
// 显示中:立即应用透明度(隐藏时由 fadeIn 负责)
|
// 显示中:立即应用透明度(隐藏时由 fadeIn 负责)
|
||||||
this.win.setOpacity(this.appearance.opacity);
|
this.win.setOpacity(this.displayOpacity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.emitAppearance();
|
this.emitAppearance();
|
||||||
@@ -1828,7 +1814,7 @@ class IslandController {
|
|||||||
!overControl;
|
!overControl;
|
||||||
if (faded === this.residentFaded) return;
|
if (faded === this.residentFaded) return;
|
||||||
this.residentFaded = faded;
|
this.residentFaded = faded;
|
||||||
this.win.setOpacity(this.appearance.opacity * (faded ? CLASSISLAND_ISLAND_METRICS.fadedOpacity : 1));
|
this.win.setOpacity(this.displayOpacity * (faded ? CLASSISLAND_ISLAND_METRICS.fadedOpacity : 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 当前岛体矩形(供冒烟验证) */
|
/** 当前岛体矩形(供冒烟验证) */
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
dayKeyLocal,
|
dayKeyLocal,
|
||||||
resolveClassStatus,
|
resolveClassStatus,
|
||||||
|
DEFAULT_LESSON_REMINDERS,
|
||||||
|
type LessonReminderSettings,
|
||||||
type IslandLessonReminder,
|
type IslandLessonReminder,
|
||||||
type ScheduleDto,
|
type ScheduleDto,
|
||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
@@ -13,7 +15,11 @@ import {
|
|||||||
export class LessonReminderTracker {
|
export class LessonReminderTracker {
|
||||||
private current: IslandLessonReminder | null = null;
|
private current: IslandLessonReminder | null = null;
|
||||||
|
|
||||||
update(schedules: ScheduleDto[], now: Date): IslandLessonReminder | null {
|
update(
|
||||||
|
schedules: ScheduleDto[],
|
||||||
|
now: Date,
|
||||||
|
settings: LessonReminderSettings = DEFAULT_LESSON_REMINDERS,
|
||||||
|
): IslandLessonReminder | null {
|
||||||
const at = now.getTime();
|
const at = now.getTime();
|
||||||
const keyOf = (s: ScheduleDto, kind: IslandLessonReminder['kind']) =>
|
const keyOf = (s: ScheduleDto, kind: IslandLessonReminder['kind']) =>
|
||||||
`${dayKeyLocal(now)}:${s.id}:${s.startTime}:${s.endTime}:${kind}`;
|
`${dayKeyLocal(now)}:${s.id}:${s.startTime}:${s.endTime}:${kind}`;
|
||||||
@@ -32,10 +38,11 @@ export class LessonReminderTracker {
|
|||||||
.sort((a, b) => b.endTime.localeCompare(a.endTime))[0];
|
.sort((a, b) => b.endTime.localeCompare(a.endTime))[0];
|
||||||
let kind: IslandLessonReminder['kind'] | null = null;
|
let kind: IslandLessonReminder['kind'] | null = null;
|
||||||
let lesson: ScheduleDto | undefined;
|
let lesson: ScheduleDto | undefined;
|
||||||
if (current && at - timeAt(current.startTime) < 5_000) {
|
if (current && at - timeAt(current.startTime) < 5_000 && settings.onClassEnabled) {
|
||||||
kind = 'on-class';
|
kind = 'on-class';
|
||||||
lesson = current;
|
lesson = current;
|
||||||
} else if (
|
} else if (
|
||||||
|
settings.breakEnabled &&
|
||||||
!current &&
|
!current &&
|
||||||
ended &&
|
ended &&
|
||||||
at - timeAt(ended.endTime) < (next ? 12_000 : 5_000) &&
|
at - timeAt(ended.endTime) < (next ? 12_000 : 5_000) &&
|
||||||
@@ -43,9 +50,24 @@ export class LessonReminderTracker {
|
|||||||
) {
|
) {
|
||||||
kind = 'break';
|
kind = 'break';
|
||||||
lesson = ended;
|
lesson = ended;
|
||||||
} else if (!current && next && timeAt(next.startTime) - at <= 60_000) {
|
} else if (
|
||||||
|
current &&
|
||||||
|
settings.endingEnabled &&
|
||||||
|
timeAt(current.endTime) - at <= settings.endingSeconds * 1000
|
||||||
|
) {
|
||||||
|
kind = 'ending';
|
||||||
|
lesson = current;
|
||||||
|
} else if (
|
||||||
|
!current &&
|
||||||
|
next &&
|
||||||
|
settings.prepareEnabled &&
|
||||||
|
timeAt(next.startTime) - at <= settings.prepareSeconds * 1000
|
||||||
|
) {
|
||||||
kind = 'prepare';
|
kind = 'prepare';
|
||||||
lesson = next;
|
lesson = next;
|
||||||
|
} else if (current && settings.classProgressEnabled) {
|
||||||
|
kind = 'in-class';
|
||||||
|
lesson = current;
|
||||||
}
|
}
|
||||||
if (!lesson || !kind) return (this.current = null);
|
if (!lesson || !kind) return (this.current = null);
|
||||||
const id = keyOf(lesson, kind);
|
const id = keyOf(lesson, kind);
|
||||||
@@ -60,13 +82,23 @@ export class LessonReminderTracker {
|
|||||||
endTime: lesson.endTime,
|
endTime: lesson.endTime,
|
||||||
startedAt: entered,
|
startedAt: entered,
|
||||||
maskUntil:
|
maskUntil:
|
||||||
kind === 'prepare'
|
kind === 'ending' || kind === 'in-class'
|
||||||
? Math.min(start, entered + 3000)
|
? entered
|
||||||
: kind === 'break' && next
|
: kind === 'prepare'
|
||||||
? entered + 2000
|
? Math.min(start, entered + 3000)
|
||||||
: entered + 5000,
|
: kind === 'break' && next
|
||||||
endsAt: kind === 'prepare' ? start : kind === 'on-class' ? start + 5000 : end + (next ? 12_000 : 5000),
|
? entered + 2000
|
||||||
|
: entered + 5000,
|
||||||
|
endsAt:
|
||||||
|
kind === 'ending' || kind === 'in-class'
|
||||||
|
? end
|
||||||
|
: kind === 'prepare'
|
||||||
|
? start
|
||||||
|
: kind === 'on-class'
|
||||||
|
? start + 5000
|
||||||
|
: end + (next ? 12_000 : 5000),
|
||||||
lessonStartsAt: start,
|
lessonStartsAt: start,
|
||||||
|
lessonEndsAt: end,
|
||||||
nextCourseName: kind === 'break' ? next?.course?.name : undefined,
|
nextCourseName: kind === 'break' ? next?.course?.name : undefined,
|
||||||
nextStartTime: kind === 'break' ? next?.startTime : undefined,
|
nextStartTime: kind === 'break' ? next?.startTime : undefined,
|
||||||
nextEndTime: kind === 'break' ? next?.endTime : undefined,
|
nextEndTime: kind === 'break' ? next?.endTime : undefined,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import { BrowserWindow } from 'electron';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { island } from './island.js';
|
import { island } from './island.js';
|
||||||
import { getConfig, saveConfig } from './config.js';
|
import { getConfig, saveConfig } from './config.js';
|
||||||
@@ -11,6 +12,10 @@ export async function verifyLayoutSmoke(
|
|||||||
await island.init(null);
|
await island.init(null);
|
||||||
island.setTestInputPassthrough(true);
|
island.setTestInputPassthrough(true);
|
||||||
const win = island.getWindow()!;
|
const win = island.getWindow()!;
|
||||||
|
const editor = BrowserWindow.getAllWindows().find(
|
||||||
|
(w) => w !== win && !w.isDestroyed() && /\/index\.html(?:#|$)/.test(w.webContents.getURL()),
|
||||||
|
)!;
|
||||||
|
let editingBounds = win.getBounds();
|
||||||
const pause = () => new Promise<void>((r) => setTimeout(r, 400));
|
const pause = () => new Promise<void>((r) => setTimeout(r, 400));
|
||||||
try {
|
try {
|
||||||
await pause();
|
await pause();
|
||||||
@@ -27,27 +32,40 @@ export async function verifyLayoutSmoke(
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
await check('自定义编辑模式可进入并添加组件行', async () => {
|
await check('自定义编辑模式可进入并添加组件行', async () => {
|
||||||
island.editLayout();
|
saveConfig({ islandLayout: { ...defaultIslandLayouts(), layout: 'custom' } });
|
||||||
|
island.syncCustomLayout();
|
||||||
await pause();
|
await pause();
|
||||||
return win.webContents.executeJavaScript(
|
editingBounds = win.getBounds();
|
||||||
`(() => { const b=[...document.querySelectorAll('button')].find(b=>b.textContent.includes('在下方新建'));b?.click();return Boolean(document.querySelector('.layout-editor')); })()`,
|
await editor.webContents.executeJavaScript('window.__classhelperSmoke__.islandEditorSmoke(true)');
|
||||||
|
await pause();
|
||||||
|
return (
|
||||||
|
(await editor.webContents.executeJavaScript(
|
||||||
|
'location.hash.includes("/settings/island/editor") && Boolean(document.querySelector(".aside"))',
|
||||||
|
)) &&
|
||||||
|
editor.webContents.executeJavaScript(
|
||||||
|
`(() => { const b=[...document.querySelectorAll('button')].find(b=>b.textContent.includes('在下方新建'));b?.click();return Boolean(document.querySelector('.layout-editor')); })()`,
|
||||||
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
await check('触摸拖动可跨行并持久化', async () => {
|
await check('触摸拖动可跨行并持久化', async () => {
|
||||||
await pause();
|
await pause();
|
||||||
await win.webContents.executeJavaScript(`(() => {
|
const dragGeometry = await editor.webContents.executeJavaScript(`(() => {
|
||||||
const handle=document.querySelector('.node-handle[data-kind=clock]');
|
const handle=document.querySelector('.node-handle[data-kind=clock]');
|
||||||
const row=document.querySelectorAll('[data-row]')[1]; if(!handle||!row)throw Error('缺少拖动目标');
|
const row=document.querySelectorAll('[data-row]')[1]; if(!handle||!row)throw Error('缺少拖动目标');
|
||||||
const r=row.getBoundingClientRect(); const x=r.left+Math.min(20,r.width/2),y=r.top+r.height/2;
|
const r=row.getBoundingClientRect(); const x=r.left+Math.min(20,r.width/2),y=r.top+r.height/2;
|
||||||
handle.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,pointerId:19,pointerType:'touch',clientX:10,clientY:10}));
|
handle.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,pointerId:19,pointerType:'touch',clientX:10,clientY:10}));
|
||||||
handle.dispatchEvent(new PointerEvent('pointermove',{bubbles:true,pointerId:19,pointerType:'touch',clientX:x,clientY:y}));
|
handle.dispatchEvent(new PointerEvent('pointermove',{bubbles:true,pointerId:19,pointerType:'touch',clientX:x,clientY:y}));
|
||||||
handle.dispatchEvent(new PointerEvent('pointerup',{bubbles:true,pointerId:19,pointerType:'touch',clientX:x,clientY:y}));
|
handle.dispatchEvent(new PointerEvent('pointerup',{bubbles:true,pointerId:19,pointerType:'touch',clientX:x,clientY:y}));
|
||||||
|
return {row:r.toJSON(), point:{x,y}, hit:document.elementFromPoint(x,y)?.outerHTML.slice(0,600), canvas:document.querySelector('.canvas')?.getBoundingClientRect().toJSON(), stage:document.querySelector('.preview-stage')?.getBoundingClientRect().toJSON()};
|
||||||
})()`);
|
})()`);
|
||||||
await pause();
|
await pause();
|
||||||
return getConfig().islandLayout.schemes[0]?.rows[1]?.nodes.some((n) => n.kind === 'clock') === true;
|
const moved =
|
||||||
|
getConfig().islandLayout.schemes[0]?.rows[1]?.nodes.some((n) => n.kind === 'clock') === true;
|
||||||
|
if (!moved) throw new Error(JSON.stringify(dragGeometry));
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
await check('组件可以包裹进容器且设置独立保存', async () => {
|
await check('组件可以包裹进容器且设置独立保存', async () => {
|
||||||
await win.webContents.executeJavaScript(
|
await editor.webContents.executeJavaScript(
|
||||||
`(() => { const select=document.querySelector('[data-action=wrap]');if(!select)throw Error('未选中组件');select.value='group';select.dispatchEvent(new Event('change',{bubbles:true})); })()`,
|
`(() => { const select=document.querySelector('[data-action=wrap]');if(!select)throw Error('未选中组件');select.value='group';select.dispatchEvent(new Event('change',{bubbles:true})); })()`,
|
||||||
);
|
);
|
||||||
await pause();
|
await pause();
|
||||||
@@ -58,38 +76,58 @@ export async function verifyLayoutSmoke(
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
await check('组件设置按内容外观高级切换,整体外观单独显示', async () => {
|
await check('组件设置按内容外观高级切换,整体外观单独显示', async () => {
|
||||||
const initial = await win.webContents.executeJavaScript(`(() => {
|
const initial = await editor.webContents.executeJavaScript(`(() => {
|
||||||
const panel=document.querySelector('.settings');
|
const panel=document.querySelector('.settings');
|
||||||
return panel?.textContent.includes('显示内容') && !panel.textContent.includes('字体大小') && !panel.textContent.includes('背景颜色');
|
return panel?.textContent.includes('显示内容') && !panel.textContent.includes('字体大小') && !panel.textContent.includes('背景颜色');
|
||||||
})()`);
|
})()`);
|
||||||
await win.webContents.executeJavaScript(`document.querySelectorAll('.panel-tabs button')[1].click()`);
|
await editor.webContents.executeJavaScript(
|
||||||
|
`document.querySelectorAll('.panel-tabs button')[1].click()`,
|
||||||
|
);
|
||||||
await pause();
|
await pause();
|
||||||
const appearance = await win.webContents.executeJavaScript(`(() => {
|
const appearance = await editor.webContents.executeJavaScript(`(() => {
|
||||||
const panel=document.querySelector('.settings');
|
const panel=document.querySelector('.settings');
|
||||||
return panel?.textContent.includes('字体大小') && !panel.textContent.includes('组件操作');
|
return panel?.textContent.includes('字体大小') && !panel.textContent.includes('组件操作');
|
||||||
})()`);
|
})()`);
|
||||||
await win.webContents.executeJavaScript(`document.querySelectorAll('.panel-tabs button')[2].click()`);
|
await editor.webContents.executeJavaScript(
|
||||||
|
`document.querySelectorAll('.panel-tabs button')[2].click()`,
|
||||||
|
);
|
||||||
await pause();
|
await pause();
|
||||||
const advanced = await win.webContents.executeJavaScript(`(() => {
|
const advanced = await editor.webContents.executeJavaScript(`(() => {
|
||||||
const panel=document.querySelector('.settings');
|
const panel=document.querySelector('.settings');
|
||||||
return panel?.textContent.includes('隐藏条件') && !panel.textContent.includes('字体大小');
|
return panel?.textContent.includes('隐藏条件') && !panel.textContent.includes('字体大小');
|
||||||
})()`);
|
})()`);
|
||||||
await win.webContents.executeJavaScript(`document.querySelectorAll('.panel-tabs button')[0].click()`);
|
await editor.webContents.executeJavaScript(
|
||||||
|
`document.querySelectorAll('.panel-tabs button')[0].click()`,
|
||||||
|
);
|
||||||
await pause();
|
await pause();
|
||||||
return initial && appearance && advanced;
|
return initial && appearance && advanced;
|
||||||
});
|
});
|
||||||
|
await check('主窗口编辑器的预览完整适配画布且不遮挡缩放控件', async () => {
|
||||||
|
return editor.webContents.executeJavaScript(`(() => {
|
||||||
|
const stage=document.querySelector('.preview-stage')?.getBoundingClientRect();
|
||||||
|
const canvas=document.querySelector('.canvas')?.getBoundingClientRect();
|
||||||
|
const footer=document.querySelector('.canvas-footer')?.getBoundingClientRect();
|
||||||
|
return Boolean(stage&&canvas&&footer&&stage.left>=canvas.left&&stage.right<=canvas.right&&stage.bottom<=footer.top-4);
|
||||||
|
})()`);
|
||||||
|
});
|
||||||
if (process.env.ISLAND_SHOTS_DIR) {
|
if (process.env.ISLAND_SHOTS_DIR) {
|
||||||
fs.mkdirSync(process.env.ISLAND_SHOTS_DIR, { recursive: true });
|
fs.mkdirSync(process.env.ISLAND_SHOTS_DIR, { recursive: true });
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(process.env.ISLAND_SHOTS_DIR, 'custom-editor.png'),
|
path.join(process.env.ISLAND_SHOTS_DIR, 'custom-editor.png'),
|
||||||
(await win.webContents.capturePage()).toPNG(),
|
(await editor.webContents.capturePage()).toPNG(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await check('退出编辑后配置可从 getConfig 读回', async () => {
|
await check('退出编辑后配置可从 getConfig 读回', async () => {
|
||||||
island.editLayout(false);
|
await editor.webContents.executeJavaScript('document.querySelector(".done-button")?.click()');
|
||||||
await pause();
|
await pause();
|
||||||
return !island.getEditingLayout() && getConfig().islandLayout.schemes[0]!.rows.length === 2;
|
return (
|
||||||
|
!island.getEditingLayout() &&
|
||||||
|
getConfig().islandLayout.schemes[0]!.rows.length === 2 &&
|
||||||
|
!editor.webContents.getURL().includes('/editor') &&
|
||||||
|
win.getBounds().width === editingBounds.width
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
await editor.webContents.executeJavaScript('window.__classhelperSmoke__.islandEditorSmoke(false)');
|
||||||
await check('自定义岛统一使用固定画布且常态组件居中适配', async () => {
|
await check('自定义岛统一使用固定画布且常态组件居中适配', async () => {
|
||||||
await pause();
|
await pause();
|
||||||
return win.webContents.executeJavaScript(`(() => {
|
return win.webContents.executeJavaScript(`(() => {
|
||||||
@@ -145,6 +183,8 @@ export async function verifyLayoutSmoke(
|
|||||||
});
|
});
|
||||||
await verifyResidentSmoke(check);
|
await verifyResidentSmoke(check);
|
||||||
} finally {
|
} finally {
|
||||||
|
if (editor && !editor.isDestroyed())
|
||||||
|
await editor.webContents.executeJavaScript('window.__classhelperSmoke__.islandEditorSmoke(false)');
|
||||||
saveConfig({ islandLayout: original });
|
saveConfig({ islandLayout: original });
|
||||||
island.syncCustomLayout();
|
island.syncCustomLayout();
|
||||||
win.hide();
|
win.hide();
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export async function verifyResidentSmoke(
|
|||||||
changed?.courseName === '临时换课' &&
|
changed?.courseName === '临时换课' &&
|
||||||
changed.maskUntil === prepare.maskUntil &&
|
changed.maskUntil === prepare.maskUntil &&
|
||||||
onClass?.kind === 'on-class' &&
|
onClass?.kind === 'on-class' &&
|
||||||
!noLate &&
|
noLate?.kind === 'in-class' &&
|
||||||
breaking?.kind === 'break' &&
|
breaking?.kind === 'break' &&
|
||||||
breaking.nextCourseName === '语文' &&
|
breaking.nextCourseName === '语文' &&
|
||||||
!after &&
|
!after &&
|
||||||
@@ -119,6 +119,32 @@ export async function verifyResidentSmoke(
|
|||||||
!removed
|
!removed
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
await check('即将下课倒数边界与四个提醒通道独立生效,旧配置自动补默认值', () => {
|
||||||
|
const settings = { ...defaultIslandLayouts().lessonReminders, classProgressEnabled: false };
|
||||||
|
const tracker = new LessonReminderTracker();
|
||||||
|
const before = tracker.update(lessonFixtures, dateAt(9, 43, 59), settings);
|
||||||
|
const ending = tracker.update(lessonFixtures, dateAt(9, 44), settings);
|
||||||
|
const disabled = tracker.update(lessonFixtures, dateAt(9, 44, 1), { ...settings, endingEnabled: false });
|
||||||
|
const prepareDisabled = tracker.update(lessonFixtures, dateAt(8, 59), {
|
||||||
|
...settings,
|
||||||
|
prepareEnabled: false,
|
||||||
|
});
|
||||||
|
const startDisabled = tracker.update(lessonFixtures, dateAt(9, 0), {
|
||||||
|
...settings,
|
||||||
|
onClassEnabled: false,
|
||||||
|
});
|
||||||
|
const breakDisabled = tracker.update(lessonFixtures, dateAt(9, 45), { ...settings, breakEnabled: false });
|
||||||
|
return (
|
||||||
|
!before &&
|
||||||
|
ending?.kind === 'ending' &&
|
||||||
|
ending.endsAt === dateAt(9, 45).getTime() &&
|
||||||
|
!disabled &&
|
||||||
|
!prepareDisabled &&
|
||||||
|
!startDisabled &&
|
||||||
|
!breakDisabled &&
|
||||||
|
normalizeIslandLayouts({ layout: 'custom' }).lessonReminders.endingEnabled
|
||||||
|
);
|
||||||
|
});
|
||||||
scheme.rows[0]!.nodes = [
|
scheme.rows[0]!.nodes = [
|
||||||
islandNode('clock', 'resident-clock'),
|
islandNode('clock', 'resident-clock'),
|
||||||
islandNode('separator', 'resident-separator'),
|
islandNode('separator', 'resident-separator'),
|
||||||
@@ -557,7 +583,59 @@ export async function verifyResidentSmoke(
|
|||||||
}
|
}
|
||||||
await setTime(9, 0, 6);
|
await setTime(9, 0, 6);
|
||||||
await pause(700);
|
await pause(700);
|
||||||
if (island.getState().lessonReminder) return false;
|
if (
|
||||||
|
layout === 'custom'
|
||||||
|
? island.getState().lessonReminder
|
||||||
|
: island.getState().lessonReminder?.kind !== 'in-class'
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
await setTime(9, 22, 30);
|
||||||
|
if (layout === 'custom') {
|
||||||
|
const progressLayout = JSON.parse(JSON.stringify(fixture));
|
||||||
|
progressLayout.schemes[0].rows[0].nodes = [islandNode('schedule', 'lesson-progress-course')];
|
||||||
|
saveConfig({ islandLayout: progressLayout });
|
||||||
|
island.syncCustomLayout();
|
||||||
|
}
|
||||||
|
await pause(1100);
|
||||||
|
const half = await win.webContents.executeJavaScript(`(() => {
|
||||||
|
const root=document.querySelector('${layout === 'custom' ? '.resident-compact' : '.ci-fixed-lesson'}');
|
||||||
|
return Boolean(root&&root.textContent.includes('数学')&&root.textContent.includes('距下课')&&Number(root.querySelector('[role=progressbar]')?.getAttribute('aria-valuenow'))===50);
|
||||||
|
})()`);
|
||||||
|
if (!half)
|
||||||
|
throw new Error(
|
||||||
|
layout +
|
||||||
|
'课中进度错误:' +
|
||||||
|
(await win.webContents.executeJavaScript(
|
||||||
|
'document.querySelector(".resident-compact, .ci-fixed-lesson")?.textContent',
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
await shot('lesson-progress-' + layout);
|
||||||
|
if (layout === 'custom') {
|
||||||
|
const countdownLayout = JSON.parse(JSON.stringify(fixture));
|
||||||
|
countdownLayout.schemes[0].rows[0].nodes = [islandNode('countdown', 'lesson-progress-countdown')];
|
||||||
|
saveConfig({ islandLayout: countdownLayout });
|
||||||
|
island.syncCustomLayout();
|
||||||
|
await pause(1100);
|
||||||
|
if (
|
||||||
|
!(await win.webContents.executeJavaScript(
|
||||||
|
`document.querySelector('.resident-compact .countdown')?.textContent.includes('距下课 22:30')`,
|
||||||
|
))
|
||||||
|
)
|
||||||
|
throw new Error('自动倒计时未切到本节下课');
|
||||||
|
}
|
||||||
|
if (layout === 'custom') {
|
||||||
|
saveConfig({ islandLayout: { ...fixture, layout } });
|
||||||
|
island.syncCustomLayout();
|
||||||
|
}
|
||||||
|
await setTime(9, 44);
|
||||||
|
await pause(700);
|
||||||
|
if (island.getState().lessonReminder?.kind !== 'ending')
|
||||||
|
throw new Error(layout + '下课倒数状态错误:' + JSON.stringify(island.getState().lessonReminder));
|
||||||
|
const ending = await win.webContents.executeJavaScript(
|
||||||
|
`document.querySelector('${layout === 'custom' ? '.resident-lesson-page' : '.ci-fixed-lesson'}')?.textContent.includes('距下课还剩')`,
|
||||||
|
);
|
||||||
|
if (!ending) throw new Error(layout + '下课倒数未渲染');
|
||||||
|
await shot('lesson-ending-' + layout);
|
||||||
await setTime(9, 45);
|
await setTime(9, 45);
|
||||||
island.setClassState({ inClass: false });
|
island.setClassState({ inClass: false });
|
||||||
await pause(700);
|
await pause(700);
|
||||||
@@ -671,36 +749,60 @@ export async function verifyResidentSmoke(
|
|||||||
island.setTouchMode(false);
|
island.setTouchMode(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await check('常驻和无通知展开内容可独立编辑并持久化', async () => {
|
await check('编辑入口交给主窗口,浮岛不会变成额外的全屏编辑窗口', async () => {
|
||||||
|
const before = win.getBounds();
|
||||||
|
let opened = false;
|
||||||
|
island.setEditLayoutHandler(() => {
|
||||||
|
opened = true;
|
||||||
|
});
|
||||||
island.editLayout();
|
island.editLayout();
|
||||||
await pause();
|
await pause(100);
|
||||||
await win.webContents.executeJavaScript(
|
|
||||||
`(() => {const button=[...document.querySelectorAll('.surface-tabs button')].find(b=>b.textContent.includes('无通知'));button?.click();})()`,
|
|
||||||
);
|
|
||||||
await pause(200);
|
|
||||||
await win.webContents.executeJavaScript(
|
|
||||||
`document.querySelector('[data-node="resident-expanded-text"]')?.click()`,
|
|
||||||
);
|
|
||||||
await pause(200);
|
|
||||||
await win.webContents.executeJavaScript(
|
|
||||||
`(() => {const input=document.querySelector('.settings textarea');if(!input)throw Error('未找到展开文本设置');input.value='放学前检查';input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}));})()`,
|
|
||||||
);
|
|
||||||
await pause();
|
|
||||||
const saved = getConfig().islandLayout.schemes[0]!;
|
|
||||||
const independent =
|
|
||||||
saved.rows[0]!.nodes.find((n) => n.id === 'resident-compact-text')?.settings.text === '常驻概览' &&
|
|
||||||
saved.expandedRows[0]!.nodes.find((n) => n.id === 'resident-expanded-text')?.settings.text ===
|
|
||||||
'放学前检查';
|
|
||||||
await shot('resident-editor');
|
|
||||||
island.editLayout(false);
|
|
||||||
await pause();
|
|
||||||
const roundtrip = normalizeIslandLayouts(JSON.parse(JSON.stringify(getConfig().islandLayout)));
|
|
||||||
return (
|
return (
|
||||||
independent &&
|
opened &&
|
||||||
roundtrip.residentEnabled &&
|
!island.getEditingLayout() &&
|
||||||
roundtrip.schemes[0]!.expandedRows[0]!.nodes.some((n) => n.settings.text === '放学前检查')
|
JSON.stringify(before) === JSON.stringify(win.getBounds()) &&
|
||||||
|
!(await win.webContents.executeJavaScript('Boolean(document.querySelector(".layout-editor"))'))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
await check('通知、作业、叫人均使用不透明背景和原生窗口,收起后恢复常驻透明度', async () => {
|
||||||
|
const appearance = island.getAppearance();
|
||||||
|
try {
|
||||||
|
island.setAppearance({ style: 'glass', opacity: 0.45, animations: false });
|
||||||
|
island.setClassState({ inClass: false, schedules: [] });
|
||||||
|
for (const layout of ['custom', 'fixed'] as const) {
|
||||||
|
saveConfig({ islandLayout: { ...fixture, layout } });
|
||||||
|
island.syncCustomLayout();
|
||||||
|
await pause(400);
|
||||||
|
for (const kind of ['notification', 'homework', 'call'] as const) {
|
||||||
|
island.handleAction({ action: 'dismiss-all' });
|
||||||
|
island.pushNotification({
|
||||||
|
...message('opaque-' + layout + '-' + kind, '老师发来的消息', 'NORMAL'),
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
island.handleAction({ action: 'expand' });
|
||||||
|
await pause(300);
|
||||||
|
const solid = await win.webContents.executeJavaScript(`(() => {
|
||||||
|
const card=document.querySelector('${layout === 'custom' ? '.resident-card' : '.island-card'}');
|
||||||
|
if (!card?.classList.contains('message-surface')) return false;
|
||||||
|
const probe=document.createElement('span');probe.style.background='var(--wn-bg)';card.append(probe);
|
||||||
|
const color=getComputedStyle(probe).backgroundColor;probe.remove();
|
||||||
|
return color==='rgb(32, 32, 32)'&&!!card.querySelector('.classic-notifications');
|
||||||
|
})()`);
|
||||||
|
if (!solid || Math.abs(win.getOpacity() - 1) > 0.01 || island.getAppearance().opacity !== 0.45)
|
||||||
|
return false;
|
||||||
|
if (kind === 'notification') await shot('message-opaque-' + layout);
|
||||||
|
}
|
||||||
|
island.handleAction({ action: 'dismiss-all' });
|
||||||
|
await pause(300);
|
||||||
|
if (layout === 'custom' && Math.abs(win.getOpacity() - 0.45) > 0.02) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
island.setAppearance(appearance);
|
||||||
|
saveConfig({ islandLayout: fixture });
|
||||||
|
island.syncCustomLayout();
|
||||||
|
}
|
||||||
|
});
|
||||||
await check('关闭常驻并选择经典岛后恢复原有隐藏方式', async () => {
|
await check('关闭常驻并选择经典岛后恢复原有隐藏方式', async () => {
|
||||||
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
|
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
|
||||||
saveConfig({ islandLayout: defaultIslandLayouts() });
|
saveConfig({ islandLayout: defaultIslandLayouts() });
|
||||||
|
|||||||
@@ -2161,7 +2161,7 @@ async function runIslandChecks(
|
|||||||
}
|
}
|
||||||
return count;
|
return count;
|
||||||
};
|
};
|
||||||
const collapseResidue = { shots: 0, max: 0 };
|
const collapseResidue = { shots: 0, stableShots: 0, max: 0 };
|
||||||
await islandWindow?.webContents.executeJavaScript(
|
await islandWindow?.webContents.executeJavaScript(
|
||||||
`(() => {
|
`(() => {
|
||||||
const button = document.querySelector('.island-card.expanded .icon-btn');
|
const button = document.querySelector('.island-card.expanded .icon-btn');
|
||||||
@@ -2170,23 +2170,46 @@ async function runIslandChecks(
|
|||||||
})()`,
|
})()`,
|
||||||
);
|
);
|
||||||
for (let attempt = 0; attempt < 22; attempt += 1) {
|
for (let attempt = 0; attempt < 22; attempt += 1) {
|
||||||
|
const before = await readIslandGeometryFrom(islandWindow!);
|
||||||
const image = await grabScreen();
|
const image = await grabScreen();
|
||||||
const geometry = await readIslandGeometryFrom(islandWindow!);
|
const geometry = await readIslandGeometryFrom(islandWindow!);
|
||||||
if (image && geometry) {
|
if (image && geometry && before) {
|
||||||
collapseResidue.shots += 1;
|
collapseResidue.shots += 1;
|
||||||
|
// desktopCapturer 返回采样期间的帧;后读 DOM 已可能收拢,不能把合法的上一动画帧算作残影。
|
||||||
|
// 动画期间排除前后矩形的并集;仍要求至少四张稳态帧,稳态只排除当前岛体。
|
||||||
|
const left = Math.min(before.island.x, geometry.island.x);
|
||||||
|
const top = Math.min(before.island.y, geometry.island.y);
|
||||||
|
const right = Math.max(
|
||||||
|
before.island.x + before.island.width,
|
||||||
|
geometry.island.x + geometry.island.width,
|
||||||
|
);
|
||||||
|
const bottom = Math.max(
|
||||||
|
before.island.y + before.island.height,
|
||||||
|
geometry.island.y + geometry.island.height,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
Math.abs(before.island.width - geometry.island.width) < 1 &&
|
||||||
|
Math.abs(before.island.height - geometry.island.height) < 1
|
||||||
|
)
|
||||||
|
collapseResidue.stableShots += 1;
|
||||||
const bitmap = image.toBitmap();
|
const bitmap = image.toBitmap();
|
||||||
const shotScale = image.getSize().width / Math.max(1, displayForCollapse.size.width);
|
const shotScale = image.getSize().width / Math.max(1, displayForCollapse.size.width);
|
||||||
collapseResidue.max = Math.max(
|
collapseResidue.max = Math.max(
|
||||||
collapseResidue.max,
|
collapseResidue.max,
|
||||||
ringResidue(bitmap, shotScale, geometry.island, geometry.window),
|
ringResidue(
|
||||||
|
bitmap,
|
||||||
|
shotScale,
|
||||||
|
{ x: left, y: top, width: right - left, height: bottom - top },
|
||||||
|
geometry.window,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await sleep(30);
|
await sleep(30);
|
||||||
}
|
}
|
||||||
record(
|
record(
|
||||||
'触摸模式收起:窗口带内不出现残影(垫底板逐帧检查,窗口让出的区域不留上一帧)',
|
'触摸模式收起:窗口带内不出现残影(垫底板逐帧检查,窗口让出的区域不留上一帧)',
|
||||||
collapseResidue.shots >= 4 && collapseResidue.max <= 400,
|
collapseResidue.stableShots >= 4 && collapseResidue.max <= 400,
|
||||||
`${collapseResidue.shots} 帧采样,窗口带内岛像素峰值=${collapseResidue.max}(该带本该全是底板色)`,
|
`${collapseResidue.shots} 帧采样(稳态 ${collapseResidue.stableShots} 帧),窗口带内岛像素峰值=${collapseResidue.max}(该带本该全是底板色)`,
|
||||||
);
|
);
|
||||||
island.setTouchMode(false);
|
island.setTouchMode(false);
|
||||||
await sleep(200);
|
await sleep(200);
|
||||||
@@ -3269,8 +3292,8 @@ async function runIslandChecks(
|
|||||||
await sleep(400);
|
await sleep(400);
|
||||||
const opacityActual = islandWindow?.getOpacity() ?? -1;
|
const opacityActual = islandWindow?.getOpacity() ?? -1;
|
||||||
record(
|
record(
|
||||||
'个性设置:透明度生效(窗口 setOpacity)',
|
'消息展示时保持不透明,同时保留用户透明度设置',
|
||||||
Math.abs(opacityActual - 0.62) < 0.03,
|
Math.abs(opacityActual - 1) < 0.03 && island.getAppearance().opacity === 0.62,
|
||||||
`设置=0.62 实际=${opacityActual.toFixed(2)}`,
|
`设置=0.62 实际=${opacityActual.toFixed(2)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ import type { IslandNotification, UpdateInfo } from '@classhelper/shared';
|
|||||||
*/
|
*/
|
||||||
const bridge: DesktopBridge = {
|
const bridge: DesktopBridge = {
|
||||||
islandEditLayout: () => ipcRenderer.invoke('island:edit-layout'),
|
islandEditLayout: () => ipcRenderer.invoke('island:edit-layout'),
|
||||||
|
onIslandEditorOpen: (handler) => {
|
||||||
|
const listener = () => handler();
|
||||||
|
ipcRenderer.on('island:open-editor', listener);
|
||||||
|
return () => ipcRenderer.removeListener('island:open-editor', listener);
|
||||||
|
},
|
||||||
smokeTest: process.env.ELECTRON_SMOKE_TEST === '1',
|
smokeTest: process.env.ELECTRON_SMOKE_TEST === '1',
|
||||||
// 冒烟凭据:只在冒烟模式下透传,供渲染进程的冒烟钩子构造测试数据用。
|
// 冒烟凭据:只在冒烟模式下透传,供渲染进程的冒烟钩子构造测试数据用。
|
||||||
// **不走代码字面量**——本文件与渲染产物都会进 app.asar,写死账号等于随安装包把教师口令发出去。
|
// **不走代码字面量**——本文件与渲染产物都会进 app.asar,写死账号等于随安装包把教师口令发出去。
|
||||||
|
|||||||
@@ -1303,9 +1303,48 @@ export async function islandScheduleSyncSelfTest(): Promise<SmokeCheckResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let restoreIslandEditorSmoke: (() => Promise<void>) | undefined;
|
||||||
|
/** 主窗口内编辑器验收使用纯本地 API 替身,结束后恢复登录态、路由与方法。 */
|
||||||
|
async function islandEditorSmoke(open: boolean): Promise<void> {
|
||||||
|
if (!open) {
|
||||||
|
await restoreIslandEditorSmoke?.();
|
||||||
|
restoreIslandEditorSmoke = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [{ api }, { useAuthStore }, { router }] = await Promise.all([
|
||||||
|
import('../api/http.js'),
|
||||||
|
import('../stores/auth.js'),
|
||||||
|
import('../router/index.js'),
|
||||||
|
]);
|
||||||
|
const auth = useAuthStore(),
|
||||||
|
token = auth.token,
|
||||||
|
path = router.currentRoute.value.fullPath,
|
||||||
|
originalGet = api.get;
|
||||||
|
const onboardingDone = (await window.desktop!.getConfig()).onboardingDone;
|
||||||
|
await window.desktop!.saveConfig({ onboardingDone: true });
|
||||||
|
api.get = (async (url: string) => {
|
||||||
|
if (url.includes('unread-count')) return { count: 0 };
|
||||||
|
if (url.includes('active-week')) return { days: [] };
|
||||||
|
if (url.includes('term')) return { currentWeek: 1, maxWeek: 40 };
|
||||||
|
return [];
|
||||||
|
}) as typeof api.get;
|
||||||
|
auth.applyToken('local-editor-smoke');
|
||||||
|
const { useOnboardingStore } = await import('../stores/onboarding.js');
|
||||||
|
useOnboardingStore().close();
|
||||||
|
restoreIslandEditorSmoke = async () => {
|
||||||
|
await router.push('/login');
|
||||||
|
auth.applyToken(token);
|
||||||
|
await router.push(path);
|
||||||
|
api.get = originalGet;
|
||||||
|
await window.desktop!.saveConfig({ onboardingDone });
|
||||||
|
};
|
||||||
|
await router.push('/settings/island/editor');
|
||||||
|
}
|
||||||
|
|
||||||
/** 注册到 window,供主进程冒烟脚本调用 */
|
/** 注册到 window,供主进程冒烟脚本调用 */
|
||||||
export function registerSmokeHooks(): void {
|
export function registerSmokeHooks(): void {
|
||||||
window.__classhelperSmoke__ = {
|
window.__classhelperSmoke__ = {
|
||||||
|
islandEditorSmoke,
|
||||||
islandScheduleSyncSelfTest,
|
islandScheduleSyncSelfTest,
|
||||||
cacheSelfTest,
|
cacheSelfTest,
|
||||||
cacheIsolationSelfTest,
|
cacheIsolationSelfTest,
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ const auth = useAuthStore();
|
|||||||
const realtime = useRealtimeStore();
|
const realtime = useRealtimeStore();
|
||||||
const notifications = useNotificationStore();
|
const notifications = useNotificationStore();
|
||||||
const ui = useUiStore();
|
const ui = useUiStore();
|
||||||
|
const stopEditorNavigation = window.desktop?.onIslandEditorOpen(
|
||||||
|
() => void router.push('/settings/island/editor'),
|
||||||
|
);
|
||||||
|
onUnmounted(() => stopEditorNavigation?.());
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主侧边栏。
|
* 主侧边栏。
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('../views/settings/IslandSettingsView.vue'),
|
component: () => import('../views/settings/IslandSettingsView.vue'),
|
||||||
meta: { title: '灵动岛' },
|
meta: { title: '灵动岛' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'settings/island/editor',
|
||||||
|
name: 'settings-island-editor',
|
||||||
|
component: () => import('../views/settings/IslandEditorView.vue'),
|
||||||
|
meta: { title: '自定义灵动岛' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'settings/reminder',
|
path: 'settings/reminder',
|
||||||
name: 'settings-reminder',
|
name: 'settings-reminder',
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import {
|
||||||
|
defaultIslandLayouts,
|
||||||
|
DEFAULT_ISLAND_APPEARANCE,
|
||||||
|
type IslandState,
|
||||||
|
type IslandAppearance,
|
||||||
|
} from '@classhelper/shared';
|
||||||
|
import CustomIsland from '../../../island/CustomIsland.vue';
|
||||||
|
import { getIslandSchedules } from '../../island/bridge.js';
|
||||||
|
const router = useRouter();
|
||||||
|
const settings = ref(defaultIslandLayouts());
|
||||||
|
const appearance = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
|
||||||
|
const schedules = ref(getIslandSchedules());
|
||||||
|
const state = ref<IslandState>({
|
||||||
|
mode: 'hidden',
|
||||||
|
active: null,
|
||||||
|
queued: [],
|
||||||
|
inClass: false,
|
||||||
|
currentPeriodEnd: null,
|
||||||
|
reason: null,
|
||||||
|
listExpanded: false,
|
||||||
|
maxCardHeight: 1024,
|
||||||
|
updatedAt: 0,
|
||||||
|
});
|
||||||
|
const ready = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
let refresh: ReturnType<typeof setInterval>;
|
||||||
|
onMounted(async () => {
|
||||||
|
const config = await window.desktop?.getConfig();
|
||||||
|
if (config) {
|
||||||
|
settings.value = { ...config.islandLayout, layout: 'custom', residentEnabled: true };
|
||||||
|
appearance.value = config.island;
|
||||||
|
await window.desktop?.saveConfig({ islandLayout: JSON.parse(JSON.stringify(settings.value)) });
|
||||||
|
}
|
||||||
|
ready.value = true;
|
||||||
|
refresh = setInterval(() => {
|
||||||
|
schedules.value = getIslandSchedules();
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
onUnmounted(() => clearInterval(refresh));
|
||||||
|
// 保存按顺序发送;快速拖动不会让较早的响应覆盖较新的草稿。
|
||||||
|
let saving = Promise.resolve();
|
||||||
|
function save(value: typeof settings.value) {
|
||||||
|
settings.value = value;
|
||||||
|
const snapshot = JSON.parse(JSON.stringify(value));
|
||||||
|
saving = saving
|
||||||
|
.then(async () => {
|
||||||
|
await window.desktop?.saveConfig({ islandLayout: snapshot });
|
||||||
|
error.value = '';
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
error.value = '保存失败,请重试';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function done() {
|
||||||
|
await saving;
|
||||||
|
if (!error.value) await router.push('/settings/island');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section class="island-editor-page" aria-label="自定义灵动岛编辑器">
|
||||||
|
<p v-if="error" role="alert">{{ error }}</p>
|
||||||
|
<CustomIsland
|
||||||
|
v-if="ready"
|
||||||
|
:settings="settings"
|
||||||
|
:appearance="appearance"
|
||||||
|
:schedules="schedules"
|
||||||
|
:state="state"
|
||||||
|
editing
|
||||||
|
embedded
|
||||||
|
@save="save"
|
||||||
|
@done="done"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<style>
|
||||||
|
.island-editor-page {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 430px;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.island-editor-page .layout-editor {
|
||||||
|
position: relative;
|
||||||
|
inset: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 430px;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
grid-template-columns: 160px minmax(200px, 1fr) 248px;
|
||||||
|
grid-template-rows: 64px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.island-editor-page .layout-editor .canvas {
|
||||||
|
padding: 110px 16px 80px;
|
||||||
|
}
|
||||||
|
.island-editor-page .layout-editor .library,
|
||||||
|
.island-editor-page .layout-editor .settings {
|
||||||
|
min-height: 0;
|
||||||
|
padding: 14px 12px;
|
||||||
|
}
|
||||||
|
.island-editor-page .layout-editor .editor-header {
|
||||||
|
padding: 10px 14px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.island-editor-page .layout-editor .editor-title p,
|
||||||
|
.island-editor-page .layout-editor .save-state {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.island-editor-page .layout-editor .scheme-picker {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.island-editor-page .layout-editor {
|
||||||
|
grid-template-columns: 130px minmax(140px, 1fr) 220px;
|
||||||
|
}
|
||||||
|
.island-editor-page .layout-editor .component-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.island-editor-page .layout-editor .scheme-picker > span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { confirmAction } from '../../composables/confirm.js';
|
import { confirmAction } from '../../composables/confirm.js';
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
import {
|
import {
|
||||||
DEFAULT_ISLAND_APPEARANCE,
|
DEFAULT_ISLAND_APPEARANCE,
|
||||||
ISLAND_APPEARANCE_RANGES as RANGES,
|
ISLAND_APPEARANCE_RANGES as RANGES,
|
||||||
@@ -23,9 +24,10 @@ import {
|
|||||||
*/
|
*/
|
||||||
const island = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
|
const island = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
|
||||||
const savingIsland = ref(false);
|
const savingIsland = ref(false);
|
||||||
|
const router = useRouter();
|
||||||
function editLayout() {
|
function editLayout() {
|
||||||
layoutMode.value = 'custom';
|
layoutMode.value = 'custom';
|
||||||
void window.desktop?.islandEditLayout();
|
void router.push('/settings/island/editor');
|
||||||
}
|
}
|
||||||
const layoutMode = ref<'fixed' | 'custom'>('fixed');
|
const layoutMode = ref<'fixed' | 'custom'>('fixed');
|
||||||
const residentEnabled = ref(false);
|
const residentEnabled = ref(false);
|
||||||
@@ -161,7 +163,7 @@ onMounted(async () => {
|
|||||||
@click="chooseLayout('fixed')"
|
@click="chooseLayout('fixed')"
|
||||||
>
|
>
|
||||||
<strong>经典灵动岛</strong>
|
<strong>经典灵动岛</strong>
|
||||||
<span>有消息时展开,保持熟悉的提醒样式</span>
|
<span>显示课程进度、上下课提醒与班级消息</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="layout-choice"
|
class="layout-choice"
|
||||||
@@ -208,7 +210,7 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
<p class="text-muted">紧急消息立即展开;课中普通通知暂存,下课后展开。自定义岛可点击铃铛主动查看。</p>
|
<p class="text-muted">紧急消息立即展开;课中普通通知暂存,下课后展开。自定义岛可点击铃铛主动查看。</p>
|
||||||
<p class="text-muted">
|
<p class="text-muted">
|
||||||
上课前 60 秒显示准备提醒和倒计时,上下课时提示课程状态;课程时间显示在名称右侧。
|
课前提示与倒计时、课中进度、即将下课倒数及上下课提醒,可在「提醒」页分别设置。课程时间显示在名称右侧。
|
||||||
</p>
|
</p>
|
||||||
<el-form label-width="110px" label-position="left">
|
<el-form label-width="110px" label-position="left">
|
||||||
<el-form-item :label="`${layoutMode === 'custom' ? '通知高度' : '高度'} ${island.height}px`">
|
<el-form-item :label="`${layoutMode === 'custom' ? '通知高度' : '高度'} ${island.height}px`">
|
||||||
|
|||||||
@@ -2,7 +2,26 @@
|
|||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
import { classGradeQueryApi } from '../../api/index.js';
|
import { classGradeQueryApi } from '../../api/index.js';
|
||||||
import { useAuthStore } from '../../stores/auth.js';
|
import { useAuthStore } from '../../stores/auth.js';
|
||||||
|
import { DEFAULT_LESSON_REMINDERS, type LessonReminderSettings } from '@classhelper/shared';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
const lesson = ref({ ...DEFAULT_LESSON_REMINDERS });
|
||||||
|
onMounted(async () => {
|
||||||
|
const config = await window.desktop?.getConfig();
|
||||||
|
if (config) lesson.value = { ...config.islandLayout.lessonReminders };
|
||||||
|
});
|
||||||
|
async function saveLesson(patch: Partial<LessonReminderSettings>) {
|
||||||
|
lesson.value = { ...lesson.value, ...patch };
|
||||||
|
const config = await window.desktop?.getConfig();
|
||||||
|
if (!config) return;
|
||||||
|
try {
|
||||||
|
await window.desktop?.saveConfig({
|
||||||
|
islandLayout: { ...config.islandLayout, lessonReminders: { ...lesson.value } },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('提醒设置保存失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
/* ------------------------------------------------------------ 班级端成绩查询状态(只读)
|
/* ------------------------------------------------------------ 班级端成绩查询状态(只读)
|
||||||
*
|
*
|
||||||
* 「是否允许班级端查看成绩」是**服务端口径**:管理员在 Web 端「班级管理」里开关,
|
* 「是否允许班级端查看成绩」是**服务端口径**:管理员在 Web 端「班级管理」里开关,
|
||||||
@@ -34,7 +53,51 @@ onMounted(loadGradeQuery);
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-card shadow="never">
|
<el-card shadow="never" class="lesson-channel-settings">
|
||||||
|
<template #header>上下课提醒</template>
|
||||||
|
<p class="text-muted">
|
||||||
|
沿用 ClassIsland 的遮罩、课前倒计时、上下课提示及下节课预告;课中显示课程进度,临近下课显示倒数。
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-for="channel in [
|
||||||
|
{ key: 'prepareEnabled', title: '准备上课提醒' },
|
||||||
|
{ key: 'onClassEnabled', title: '上课提醒' },
|
||||||
|
{ key: 'endingEnabled', title: '即将下课倒数' },
|
||||||
|
{ key: 'classProgressEnabled', title: '经典岛课中常显课程进度' },
|
||||||
|
{ key: 'breakEnabled', title: '下课提醒与下节课预告' },
|
||||||
|
] as const"
|
||||||
|
:key="channel.key"
|
||||||
|
class="lesson-setting-row"
|
||||||
|
>
|
||||||
|
<span>{{ channel.title }}</span>
|
||||||
|
<el-switch
|
||||||
|
:model-value="lesson[channel.key]"
|
||||||
|
:aria-label="channel.title"
|
||||||
|
@change="(v: boolean | string | number) => saveLesson({ [channel.key]: v === true })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="lesson-setting-row">
|
||||||
|
<span>课前提前(秒)</span>
|
||||||
|
<el-input-number
|
||||||
|
:model-value="lesson.prepareSeconds"
|
||||||
|
:min="5"
|
||||||
|
:max="1800"
|
||||||
|
:step="5"
|
||||||
|
@change="(v: number | undefined) => saveLesson({ prepareSeconds: v ?? 60 })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="lesson-setting-row">
|
||||||
|
<span>下课倒数提前(秒)</span>
|
||||||
|
<el-input-number
|
||||||
|
:model-value="lesson.endingSeconds"
|
||||||
|
:min="5"
|
||||||
|
:max="600"
|
||||||
|
:step="5"
|
||||||
|
@change="(v: number | undefined) => saveLesson({ endingSeconds: v ?? 60 })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
<el-card shadow="never" class="mt-16">
|
||||||
<template #header>通知提醒</template>
|
<template #header>通知提醒</template>
|
||||||
<p class="text-muted">
|
<p class="text-muted">
|
||||||
通知与叫人通过 ClassHelper 客户端和灵动岛提醒。普通通知在上课期间暂存,下课后补弹;紧急通知立即提醒。
|
通知与叫人通过 ClassHelper 客户端和灵动岛提醒。普通通知在上课期间暂存,下课后补弹;紧急通知立即提醒。
|
||||||
@@ -62,6 +125,17 @@ onMounted(loadGradeQuery);
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.lesson-setting-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid var(--ch-border);
|
||||||
|
}
|
||||||
|
.lesson-setting-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
.channel-group {
|
.channel-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export interface IslandClassStatePayload {
|
|||||||
|
|
||||||
export interface DesktopBridge {
|
export interface DesktopBridge {
|
||||||
islandEditLayout(): Promise<void>;
|
islandEditLayout(): Promise<void>;
|
||||||
|
onIslandEditorOpen(handler: () => void): () => void;
|
||||||
getUpdateProgress(): Promise<ContractReleaseProgressDto>;
|
getUpdateProgress(): Promise<ContractReleaseProgressDto>;
|
||||||
onUpdateProgress(handler: (progress: ContractReleaseProgressDto) => void): () => void;
|
onUpdateProgress(handler: (progress: ContractReleaseProgressDto) => void): () => void;
|
||||||
/** 是否处于冒烟验证模式(由 ELECTRON_SMOKE_TEST=1 触发),同步可读 */
|
/** 是否处于冒烟验证模式(由 ELECTRON_SMOKE_TEST=1 触发),同步可读 */
|
||||||
@@ -253,6 +254,7 @@ declare global {
|
|||||||
island?: IslandRendererBridge;
|
island?: IslandRendererBridge;
|
||||||
/** 冒烟验证钩子,仅在 ELECTRON_SMOKE_TEST=1 时由渲染进程注册 */
|
/** 冒烟验证钩子,仅在 ELECTRON_SMOKE_TEST=1 时由渲染进程注册 */
|
||||||
__classhelperSmoke__?: {
|
__classhelperSmoke__?: {
|
||||||
|
islandEditorSmoke(open: boolean): Promise<void>;
|
||||||
islandScheduleSyncSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
islandScheduleSyncSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||||
cacheSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
cacheSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||||
cacheIsolationSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
cacheIsolationSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||||
|
|||||||
@@ -71,10 +71,11 @@ export interface IslandComponentNode {
|
|||||||
template: boolean;
|
template: boolean;
|
||||||
hour12: boolean;
|
hour12: boolean;
|
||||||
seconds: boolean;
|
seconds: boolean;
|
||||||
target: 'next' | 'school';
|
target: 'auto' | 'next' | 'end' | 'school';
|
||||||
display: 'current' | 'next' | 'today';
|
display: 'current' | 'next' | 'today';
|
||||||
count: number;
|
count: number;
|
||||||
showPeriod: boolean;
|
showPeriod: boolean;
|
||||||
|
showProgress: boolean;
|
||||||
direction: 'horizontal' | 'vertical';
|
direction: 'horizontal' | 'vertical';
|
||||||
gap: number;
|
gap: number;
|
||||||
interval: number;
|
interval: number;
|
||||||
@@ -116,9 +117,29 @@ export interface IslandLayoutSettings {
|
|||||||
residentEnabled: boolean;
|
residentEnabled: boolean;
|
||||||
pointerFadeEnabled: boolean;
|
pointerFadeEnabled: boolean;
|
||||||
pointerFadeReversed: boolean;
|
pointerFadeReversed: boolean;
|
||||||
|
lessonReminders: LessonReminderSettings;
|
||||||
activeSchemeId: string;
|
activeSchemeId: string;
|
||||||
schemes: IslandLayoutScheme[];
|
schemes: IslandLayoutScheme[];
|
||||||
}
|
}
|
||||||
|
/** 上下课通道独立开关;倒数阈值以秒计,配置随本机方案保存。 */
|
||||||
|
export interface LessonReminderSettings {
|
||||||
|
prepareEnabled: boolean;
|
||||||
|
onClassEnabled: boolean;
|
||||||
|
breakEnabled: boolean;
|
||||||
|
endingEnabled: boolean;
|
||||||
|
classProgressEnabled: boolean;
|
||||||
|
prepareSeconds: number;
|
||||||
|
endingSeconds: number;
|
||||||
|
}
|
||||||
|
export const DEFAULT_LESSON_REMINDERS: LessonReminderSettings = {
|
||||||
|
prepareEnabled: true,
|
||||||
|
onClassEnabled: true,
|
||||||
|
breakEnabled: true,
|
||||||
|
endingEnabled: true,
|
||||||
|
classProgressEnabled: true,
|
||||||
|
prepareSeconds: 60,
|
||||||
|
endingSeconds: 60,
|
||||||
|
};
|
||||||
function defaultExpandedRows(): IslandLayoutRow[] {
|
function defaultExpandedRows(): IslandLayoutRow[] {
|
||||||
const course = islandNode('schedule', 'expanded-schedule');
|
const course = islandNode('schedule', 'expanded-schedule');
|
||||||
course.settings.display = 'today';
|
course.settings.display = 'today';
|
||||||
@@ -143,10 +164,11 @@ export function islandNode(kind: IslandComponentKind, id: string): IslandCompone
|
|||||||
template: false,
|
template: false,
|
||||||
hour12: false,
|
hour12: false,
|
||||||
seconds: true,
|
seconds: true,
|
||||||
target: 'next',
|
target: 'auto',
|
||||||
display: 'current',
|
display: 'current',
|
||||||
count: 5,
|
count: 5,
|
||||||
showPeriod: true,
|
showPeriod: true,
|
||||||
|
showProgress: true,
|
||||||
direction: 'horizontal',
|
direction: 'horizontal',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
interval: 5,
|
interval: 5,
|
||||||
@@ -177,6 +199,7 @@ export function defaultIslandLayouts(): IslandLayoutSettings {
|
|||||||
residentEnabled: false,
|
residentEnabled: false,
|
||||||
pointerFadeEnabled: true,
|
pointerFadeEnabled: true,
|
||||||
pointerFadeReversed: false,
|
pointerFadeReversed: false,
|
||||||
|
lessonReminders: { ...DEFAULT_LESSON_REMINDERS },
|
||||||
activeSchemeId: 'default',
|
activeSchemeId: 'default',
|
||||||
schemes: [
|
schemes: [
|
||||||
{
|
{
|
||||||
@@ -237,10 +260,11 @@ export function normalizeIslandLayouts(input: unknown): IslandLayoutSettings {
|
|||||||
template: s.template === true,
|
template: s.template === true,
|
||||||
hour12: s.hour12 === true,
|
hour12: s.hour12 === true,
|
||||||
seconds: s.seconds !== false,
|
seconds: s.seconds !== false,
|
||||||
target: s.target === 'school' ? 'school' : 'next',
|
target: ['auto', 'next', 'end', 'school'].includes(s.target) ? s.target : 'auto',
|
||||||
display: ['current', 'next', 'today'].includes(s.display) ? s.display : 'current',
|
display: ['current', 'next', 'today'].includes(s.display) ? s.display : 'current',
|
||||||
count: clamp(s.count, 1, 40, 5),
|
count: clamp(s.count, 1, 40, 5),
|
||||||
showPeriod: s.showPeriod !== false,
|
showPeriod: s.showPeriod !== false,
|
||||||
|
showProgress: s.showProgress !== false,
|
||||||
direction: s.direction === 'vertical' ? 'vertical' : 'horizontal',
|
direction: s.direction === 'vertical' ? 'vertical' : 'horizontal',
|
||||||
gap: clamp(s.gap, 0, 64, 8),
|
gap: clamp(s.gap, 0, 64, 8),
|
||||||
interval: clamp(s.interval, 1, 300, 5),
|
interval: clamp(s.interval, 1, 300, 5),
|
||||||
@@ -331,6 +355,15 @@ export function normalizeIslandLayouts(input: unknown): IslandLayoutSettings {
|
|||||||
residentEnabled: raw.layout === 'custom',
|
residentEnabled: raw.layout === 'custom',
|
||||||
pointerFadeEnabled: raw.pointerFadeEnabled !== false,
|
pointerFadeEnabled: raw.pointerFadeEnabled !== false,
|
||||||
pointerFadeReversed: raw.pointerFadeReversed === true,
|
pointerFadeReversed: raw.pointerFadeReversed === true,
|
||||||
|
lessonReminders: {
|
||||||
|
prepareEnabled: raw.lessonReminders?.prepareEnabled !== false,
|
||||||
|
onClassEnabled: raw.lessonReminders?.onClassEnabled !== false,
|
||||||
|
breakEnabled: raw.lessonReminders?.breakEnabled !== false,
|
||||||
|
endingEnabled: raw.lessonReminders?.endingEnabled !== false,
|
||||||
|
classProgressEnabled: raw.lessonReminders?.classProgressEnabled !== false,
|
||||||
|
prepareSeconds: clamp(raw.lessonReminders?.prepareSeconds, 5, 1800, 60),
|
||||||
|
endingSeconds: clamp(raw.lessonReminders?.endingSeconds, 5, 600, 60),
|
||||||
|
},
|
||||||
schemes: schemes.length ? schemes : fallback.schemes,
|
schemes: schemes.length ? schemes : fallback.schemes,
|
||||||
activeSchemeId: schemes.some((s) => s.id === raw.activeSchemeId)
|
activeSchemeId: schemes.some((s) => s.id === raw.activeSchemeId)
|
||||||
? raw.activeSchemeId
|
? raw.activeSchemeId
|
||||||
|
|||||||
@@ -1073,7 +1073,7 @@ export type IslandNotificationKind = 'notification' | 'homework' | 'call';
|
|||||||
/** 本机课表产生的临时提醒,不进入教师通知队列或已读记录。 */
|
/** 本机课表产生的临时提醒,不进入教师通知队列或已读记录。 */
|
||||||
export interface IslandLessonReminder {
|
export interface IslandLessonReminder {
|
||||||
id: string;
|
id: string;
|
||||||
kind: 'prepare' | 'on-class' | 'break';
|
kind: 'prepare' | 'on-class' | 'break' | 'ending' | 'in-class';
|
||||||
courseName: string;
|
courseName: string;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
endTime: string;
|
endTime: string;
|
||||||
@@ -1081,6 +1081,7 @@ export interface IslandLessonReminder {
|
|||||||
maskUntil: number;
|
maskUntil: number;
|
||||||
endsAt: number;
|
endsAt: number;
|
||||||
lessonStartsAt: number;
|
lessonStartsAt: number;
|
||||||
|
lessonEndsAt?: number;
|
||||||
nextCourseName?: string;
|
nextCourseName?: string;
|
||||||
nextStartTime?: string;
|
nextStartTime?: string;
|
||||||
nextEndTime?: string;
|
nextEndTime?: string;
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, ref, watch } from 'vue';
|
||||||
|
import type { MenuInstance } from 'element-plus';
|
||||||
|
import type { ManagementMenuGroup } from '@/config/navigation';
|
||||||
|
const props = defineProps<{ groups: ManagementMenuGroup[]; active: string; collapsed?: boolean }>();
|
||||||
|
const emit = defineEmits<{ select: [path: string]; expand: [] }>();
|
||||||
|
const menu = ref<MenuInstance>();
|
||||||
|
watch(
|
||||||
|
() => [props.active, props.groups],
|
||||||
|
async () => {
|
||||||
|
await nextTick();
|
||||||
|
const group = props.groups.find((g) => g.items.some((i) => i.path === props.active));
|
||||||
|
if (group) menu.value?.open(group.id);
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<el-menu
|
||||||
|
ref="menu"
|
||||||
|
:default-active="active"
|
||||||
|
:default-openeds="groups.filter((g) => g.items.some((i) => i.path === active)).map((g) => g.id)"
|
||||||
|
class="layout-menu management-menu"
|
||||||
|
:class="{ 'menu-rail': collapsed }"
|
||||||
|
unique-opened
|
||||||
|
@select="emit('select', $event)"
|
||||||
|
>
|
||||||
|
<el-sub-menu v-for="group in groups" :key="group.id" :index="group.id">
|
||||||
|
<template #title>
|
||||||
|
<div
|
||||||
|
class="management-group-title"
|
||||||
|
:title="collapsed ? group.title : undefined"
|
||||||
|
@click="collapsed && emit('expand')"
|
||||||
|
>
|
||||||
|
<el-icon><component :is="group.icon" /></el-icon>
|
||||||
|
<span>{{ group.title }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-menu-item v-for="item in group.items" :key="item.path" :index="item.path" :data-path="item.path">
|
||||||
|
<el-icon><component :is="item.icon" /></el-icon>
|
||||||
|
<span>{{ item.title }}</span>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-sub-menu>
|
||||||
|
</el-menu>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.management-menu.layout-menu {
|
||||||
|
--el-menu-text-color: var(--ch-text-secondary);
|
||||||
|
--el-menu-active-color: var(--el-color-primary);
|
||||||
|
--el-menu-hover-bg-color: var(--ch-surface-alt);
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 12px 8px;
|
||||||
|
}
|
||||||
|
.management-group-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.management-group-title .el-icon {
|
||||||
|
flex: 0 0 20px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.management-menu :deep(.el-sub-menu__title) {
|
||||||
|
padding: 0 14px;
|
||||||
|
height: 46px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--ch-text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.management-menu :deep(.el-sub-menu.is-active > .el-sub-menu__title) {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
.management-menu :deep(.el-menu) {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.management-menu :deep(.el-menu-item) {
|
||||||
|
height: 42px;
|
||||||
|
padding-left: 26px;
|
||||||
|
margin: 3px 0 3px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--ch-text-secondary);
|
||||||
|
}
|
||||||
|
.management-menu.layout-menu :deep(.el-menu-item.is-active) {
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.management-menu.menu-rail :deep(.el-sub-menu > .el-menu) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.management-menu.menu-rail :deep(.el-sub-menu__icon-arrow),
|
||||||
|
.management-menu.menu-rail .management-group-title span {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
.management-menu.menu-rail :deep(.el-sub-menu__title) {
|
||||||
|
padding: 0 14px !important;
|
||||||
|
}
|
||||||
|
.management-menu.menu-rail {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/** 只定义分组;子页标题、图标和权限直接读取路由,避免菜单与路由各维护一套权限。 */
|
||||||
|
export const MANAGEMENT_GROUPS = [
|
||||||
|
{ id: 'workspace', title: '工作台', icon: 'Odometer', paths: ['/dashboard', '/assistant'] },
|
||||||
|
{
|
||||||
|
id: 'teaching',
|
||||||
|
title: '教学事务',
|
||||||
|
icon: 'Notebook',
|
||||||
|
paths: ['/schedules', '/plan-editor', '/homeworks', '/notifications', '/grades'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'school',
|
||||||
|
title: '班级与人员',
|
||||||
|
icon: 'School',
|
||||||
|
paths: ['/classes', '/students', '/teachers', '/roster', '/term-weeks', '/archives'],
|
||||||
|
},
|
||||||
|
{ id: 'devices', title: '设备运维', icon: 'Monitor', paths: ['/clients'] },
|
||||||
|
{
|
||||||
|
id: 'system',
|
||||||
|
title: '系统管理',
|
||||||
|
icon: 'Setting',
|
||||||
|
paths: ['/permissions', '/system-settings', '/ai-settings', '/database'],
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
export interface ManagementMenuItem {
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
export interface ManagementMenuGroup {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
items: ManagementMenuItem[];
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
|
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
||||||
import { STORAGE_KEYS, type PermissionKey, type UserRole } from '@classhelper/shared';
|
import { STORAGE_KEYS, type PermissionKey } from '@classhelper/shared';
|
||||||
import { authApi } from '@/api';
|
import { authApi } from '@/api';
|
||||||
|
import ManagementMenu from '@/components/ManagementMenu.vue';
|
||||||
|
import { MANAGEMENT_GROUPS, type ManagementMenuItem } from '@/config/navigation';
|
||||||
import UpdateCheckDialog from '@/components/UpdateCheckDialog.vue';
|
import UpdateCheckDialog from '@/components/UpdateCheckDialog.vue';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { useRealtimeStore } from '@/stores/realtime';
|
import { useRealtimeStore } from '@/stores/realtime';
|
||||||
@@ -87,90 +89,72 @@ function openAssistant(): void {
|
|||||||
* (`permission:manage` / `client:manage`)—— 教师被某个权限组授予它之后同样该看见
|
* (`permission:manage` / `client:manage`)—— 教师被某个权限组授予它之后同样该看见
|
||||||
* (见 `visibleMenuItems`)
|
* (见 `visibleMenuItems`)
|
||||||
*/
|
*/
|
||||||
interface MenuItem {
|
const menuItems = router
|
||||||
path: string;
|
.getRoutes()
|
||||||
title: string;
|
.filter((record) => MANAGEMENT_GROUPS.some((g) => (g.paths as readonly string[]).includes(record.path)))
|
||||||
icon: string;
|
.map((record) => ({
|
||||||
roles: UserRole[];
|
path: record.path,
|
||||||
/** 可选的**生效权限**门控:有值时还要 `auth.can()` 通过才显示(角色判不出来这一层) */
|
title: String(record.meta.title || record.path),
|
||||||
permission?: PermissionKey;
|
icon: String(record.meta.icon || 'Setting'),
|
||||||
}
|
roles: record.meta.roles as string[] | undefined,
|
||||||
|
permission: record.meta.permission as PermissionKey | undefined,
|
||||||
const menuItems: MenuItem[] = [
|
}));
|
||||||
{
|
|
||||||
path: '/plan-editor',
|
|
||||||
title: '时间表与课表编辑',
|
|
||||||
icon: 'Calendar',
|
|
||||||
roles: ['ADMIN', 'TEACHER'],
|
|
||||||
permission: 'schedule:read',
|
|
||||||
},
|
|
||||||
{ path: '/roster', title: '学生名单', icon: 'User', roles: ['TEACHER'], permission: 'roster:read' },
|
|
||||||
{
|
|
||||||
path: '/system-settings',
|
|
||||||
title: '系统设置',
|
|
||||||
icon: 'Setting',
|
|
||||||
roles: ['ADMIN', 'TEACHER'],
|
|
||||||
permission: 'system:manage',
|
|
||||||
},
|
|
||||||
{ path: '/dashboard', title: '仪表盘', icon: 'Odometer', roles: ['ADMIN', 'TEACHER'] },
|
|
||||||
{ path: '/classes', title: '班级管理', icon: 'School', roles: ['ADMIN'] },
|
|
||||||
{ path: '/students', title: '学生管理', icon: 'User', roles: ['ADMIN'] },
|
|
||||||
{ path: '/teachers', title: '教师管理', icon: 'UserFilled', roles: ['ADMIN'] },
|
|
||||||
{ path: '/term-weeks', title: '学期周次', icon: 'Calendar', roles: ['ADMIN'] },
|
|
||||||
{ path: '/archives', title: '毕业归档', icon: 'FolderOpened', roles: ['ADMIN'] },
|
|
||||||
{
|
|
||||||
path: '/permissions',
|
|
||||||
title: '权限组',
|
|
||||||
icon: 'Lock',
|
|
||||||
roles: ['ADMIN', 'TEACHER'],
|
|
||||||
permission: 'permission:manage',
|
|
||||||
},
|
|
||||||
{ path: '/schedules', title: '课表管理', icon: 'Calendar', roles: ['ADMIN', 'TEACHER'] },
|
|
||||||
{ path: '/homeworks', title: '作业发布', icon: 'Notebook', roles: ['ADMIN', 'TEACHER'] },
|
|
||||||
{ path: '/notifications', title: '通知发布', icon: 'Bell', roles: ['ADMIN', 'TEACHER'] },
|
|
||||||
{ path: '/grades', title: '成绩录入', icon: 'Trophy', roles: ['ADMIN', 'TEACHER'] },
|
|
||||||
{
|
|
||||||
path: '/assistant',
|
|
||||||
title: 'AI 助手',
|
|
||||||
icon: 'MagicStick',
|
|
||||||
// `ai:use` 默认给 ADMIN + TEACHER(教师用一句话发布作业/通知/叫人),
|
|
||||||
// 但仍走**生效权限**细筛:管理员把 ai:use 从某个权限组收回后这里就该消失。
|
|
||||||
roles: ['ADMIN', 'TEACHER'],
|
|
||||||
permission: 'ai:use',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/clients',
|
|
||||||
title: '客户端',
|
|
||||||
icon: 'Monitor',
|
|
||||||
// 按生效权限展示入口;页内分别校验设备管理、远控和更新权限。
|
|
||||||
roles: ['ADMIN', 'TEACHER'],
|
|
||||||
permission: 'client:read',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/ai-settings',
|
|
||||||
title: '大模型设置',
|
|
||||||
icon: 'Setting',
|
|
||||||
// 与「权限组」「客户端设备」同一道门控:只有 ai:manage(默认仅管理员)才看得见,
|
|
||||||
// 页面里的配置读写也要求它(教师访问会 403)。
|
|
||||||
roles: ['ADMIN', 'TEACHER'],
|
|
||||||
permission: 'ai:manage',
|
|
||||||
},
|
|
||||||
{ path: '/database', title: '数据库管理', icon: 'Coin', roles: ['ADMIN'] },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 当前账号可见的菜单。
|
|
||||||
*
|
|
||||||
* 两道筛子:`roles` 是粗筛(角色级,与后端 `requireRole` 对齐),`permission` 是细筛
|
|
||||||
* (生效权限级)——「权限组」必须走后者:教师被权限组授予 `permission:manage` 后
|
|
||||||
* 角色还是 TEACHER,靠 `roles` 判断不出来。所有角色都等待生效权限,加载失败时不放行。
|
|
||||||
*/
|
|
||||||
const visibleMenuItems = computed(() =>
|
const visibleMenuItems = computed(() =>
|
||||||
menuItems.filter((item) => {
|
menuItems.filter(
|
||||||
if (!auth.role || !item.roles.includes(auth.role)) return false;
|
(item) =>
|
||||||
return !item.permission || auth.can(item.permission);
|
!!auth.role &&
|
||||||
}),
|
(!item.roles?.length || item.roles.includes(auth.role)) &&
|
||||||
|
(!item.permission || auth.can(item.permission)),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
const menuGroups = computed(() =>
|
||||||
|
MANAGEMENT_GROUPS.map((group) => ({
|
||||||
|
...group,
|
||||||
|
items: group.paths.flatMap((path) => visibleMenuItems.value.filter((item) => item.path === path)),
|
||||||
|
})).filter((group) => group.items.length),
|
||||||
|
);
|
||||||
|
const currentGroup = computed(() =>
|
||||||
|
menuGroups.value.find((group) => group.items.some((item) => item.path === route.path)),
|
||||||
|
);
|
||||||
|
const searchVisible = ref(false);
|
||||||
|
const searchQuery = ref('');
|
||||||
|
const searchResults = computed(() =>
|
||||||
|
visibleMenuItems.value.filter((item) => item.title.includes(searchQuery.value.trim())),
|
||||||
|
);
|
||||||
|
const tabs = ref<Array<ManagementMenuItem & { fullPath: string }>>([]);
|
||||||
|
function addCurrentTab() {
|
||||||
|
const item = visibleMenuItems.value.find((item) => item.path === route.path);
|
||||||
|
if (!item) return;
|
||||||
|
const tab = tabs.value.find((tab) => tab.path === item.path);
|
||||||
|
if (tab) tab.fullPath = route.fullPath;
|
||||||
|
else tabs.value.push({ path: item.path, title: item.title, icon: item.icon, fullPath: route.fullPath });
|
||||||
|
tabs.value = tabs.value.filter((tab) => visibleMenuItems.value.some((item) => item.path === tab.path));
|
||||||
|
if (tabs.value.length > 20) tabs.value.splice(tabs.value[0]?.path === '/dashboard' ? 1 : 0, 1);
|
||||||
|
}
|
||||||
|
watch([() => route.fullPath, visibleMenuItems], addCurrentTab, { immediate: true });
|
||||||
|
function closeTab(path: string) {
|
||||||
|
if (path === '/dashboard') return;
|
||||||
|
const i = tabs.value.findIndex((tab) => tab.path === path);
|
||||||
|
if (i < 0) return;
|
||||||
|
tabs.value.splice(i, 1);
|
||||||
|
if (route.path === path) void router.push((tabs.value[i] ?? tabs.value[i - 1])?.fullPath || '/dashboard');
|
||||||
|
}
|
||||||
|
function closeOtherTabs() {
|
||||||
|
tabs.value = tabs.value.filter((tab) => tab.path === route.path || tab.path === '/dashboard');
|
||||||
|
}
|
||||||
|
function searchNavigate(path: string) {
|
||||||
|
searchVisible.value = false;
|
||||||
|
searchQuery.value = '';
|
||||||
|
void router.push(path);
|
||||||
|
}
|
||||||
|
function handleSearchShortcut(event: KeyboardEvent) {
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
|
||||||
|
event.preventDefault();
|
||||||
|
searchVisible.value = !searchVisible.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(() => window.addEventListener('keydown', handleSearchShortcut));
|
||||||
|
onUnmounted(() => window.removeEventListener('keydown', handleSearchShortcut));
|
||||||
|
|
||||||
/** 高亮当前菜单:直接比较路由路径,避免依赖路由名 */
|
/** 高亮当前菜单:直接比较路由路径,避免依赖路由名 */
|
||||||
const activeMenu = computed(() => route.path);
|
const activeMenu = computed(() => route.path);
|
||||||
@@ -178,172 +162,10 @@ const activeMenu = computed(() => route.path);
|
|||||||
/** 顶栏显示的当前页面标题(小屏下替代侧边栏的"我在哪"提示,1Panel 同款做法) */
|
/** 顶栏显示的当前页面标题(小屏下替代侧边栏的"我在哪"提示,1Panel 同款做法) */
|
||||||
const currentTitle = computed(() => menuItems.find((item) => item.path === route.path)?.title ?? APP_TITLE);
|
const currentTitle = computed(() => menuItems.find((item) => item.path === route.path)?.title ?? APP_TITLE);
|
||||||
|
|
||||||
/* -------------------------------------------------------------- 导航指示器 */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 侧栏激活项的**滑动指示器**(beUI 的 `shared-layout-bg` / tabs 的 layoutId 手法)。
|
|
||||||
*
|
|
||||||
* 做法是**一个**绝对定位的药丸跟着激活项平移,而不是让每个菜单项各自画一块底色。
|
|
||||||
* 后者在切换时是"旧的瞬间消失、新的瞬间出现",看不出两者之间的关系;
|
|
||||||
* 前者的滑动把「我从这一项去了那一项」这个关系画了出来 —— 这是整个动效层里
|
|
||||||
* 唯一一处"传达信息"而非"装饰"的动画。
|
|
||||||
*
|
|
||||||
* 位置靠**量 DOM**而不是写死数值:菜单项的位置由 Element Plus 的 padding 与行高决定,
|
|
||||||
* 写死会在组件库升级、字号变化或菜单文案变长时错位。
|
|
||||||
* 用 ResizeObserver 而不是 window.resize:侧栏宽度/菜单重排都可能由非窗口因素触发。
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* `el-aside` 是**组件**而不是原生标签:模板 ref 拿到的是组件实例,
|
|
||||||
* 直接 `.querySelector()` 会 `TypeError: not a function`(实测踩过,而且被
|
|
||||||
* `app.config.errorHandler` 吞成了控制台里一行中文异常,界面看着毫无异样)。
|
|
||||||
* 组件实例的 `$el` 才是它真正渲染出来的那个 `<aside>` 元素。
|
|
||||||
*/
|
|
||||||
type AsideRef = { $el?: HTMLElement } | HTMLElement | null;
|
|
||||||
const asideRef = ref<AsideRef>(null);
|
|
||||||
|
|
||||||
function asideElement(): HTMLElement | null {
|
|
||||||
const value = asideRef.value;
|
|
||||||
if (!value) return null;
|
|
||||||
return value instanceof HTMLElement ? value : (value.$el ?? null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const indicator = ref({ x: 0, y: 0, width: 0, height: 0, visible: false });
|
|
||||||
/** 指示器的裁剪框 = 菜单**视口**(滚动容器自身),滚动时激活项滚出去就跟着被裁掉 */
|
|
||||||
const indicatorClip = ref({ top: 0, left: 0, width: 0, height: 0 });
|
|
||||||
|
|
||||||
/** 菜单滚动容器:桌面侧栏里的 .el-menu(小屏是抽屉里的另一个 el-menu,没有指示器) */
|
|
||||||
function menuScroller(): HTMLElement | null {
|
|
||||||
return asideElement()?.querySelector<HTMLElement>('.layout-menu') ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncIndicator(): void {
|
|
||||||
const aside = asideElement();
|
|
||||||
const scroller = menuScroller();
|
|
||||||
const active = scroller?.querySelector<HTMLElement>('.el-menu-item.is-active');
|
|
||||||
if (!aside || !scroller || !active) {
|
|
||||||
indicator.value = { ...indicator.value, visible: false };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const asideRect = aside.getBoundingClientRect();
|
|
||||||
const box = scroller.getBoundingClientRect();
|
|
||||||
/*
|
|
||||||
* 以菜单视口为基准量,而不是以整个侧栏为基准。
|
|
||||||
*
|
|
||||||
* 菜单列是可滚动的(见 .layout-menu 的 overflow):量侧栏的话,激活项被滚出视口后
|
|
||||||
* 指示器会"贴"到品牌区/页脚上。裁剪框跟着菜单视口,指示器就只在菜单里可见。
|
|
||||||
*/
|
|
||||||
indicatorClip.value = {
|
|
||||||
top: box.top - asideRect.top,
|
|
||||||
left: box.left - asideRect.left,
|
|
||||||
width: box.width,
|
|
||||||
height: box.height,
|
|
||||||
};
|
|
||||||
const rect = active.getBoundingClientRect();
|
|
||||||
indicator.value = {
|
|
||||||
x: rect.left - box.left,
|
|
||||||
y: rect.top - box.top,
|
|
||||||
width: rect.width,
|
|
||||||
height: rect.height,
|
|
||||||
visible: rect.width > 0 && rect.height > 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const indicatorClipStyle = computed(() => ({
|
|
||||||
top: `${indicatorClip.value.top}px`,
|
|
||||||
left: `${indicatorClip.value.left}px`,
|
|
||||||
width: `${indicatorClip.value.width}px`,
|
|
||||||
height: `${indicatorClip.value.height}px`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const indicatorStyle = computed(() => ({
|
|
||||||
width: `${indicator.value.width}px`,
|
|
||||||
height: `${indicator.value.height}px`,
|
|
||||||
transform: `translate(${indicator.value.x}px, ${indicator.value.y}px)`,
|
|
||||||
opacity: indicator.value.visible ? '1' : '0',
|
|
||||||
}));
|
|
||||||
|
|
||||||
let asideObserver: ResizeObserver | null = null;
|
|
||||||
let indicatorFrame = 0;
|
|
||||||
let scrollerEl: HTMLElement | null = null;
|
|
||||||
let scrollSettleTimer = 0;
|
|
||||||
/** 正在跟随滚动:这期间关掉过渡,否则每帧都重新起一段 397ms 的弹簧,看起来"慢吞吞地追" */
|
|
||||||
const indicatorFollowing = ref(false);
|
|
||||||
const SCROLL_SETTLE_MS = 140;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 把测量推迟到下一帧再跑。
|
|
||||||
*
|
|
||||||
* 侧栏折叠时 `.aside` 的宽度有一整段过渡,`ResizeObserver` 会逐帧回调;若在回调里**同步**
|
|
||||||
* 读 `getBoundingClientRect()` 再写样式,就是在每一帧中间强行插入一次同步布局
|
|
||||||
* (Chromium 还会因此报 "ResizeObserver loop completed with undelivered notifications")。
|
|
||||||
* 放进 rAF 就变成"本帧布局完成后统一量一次",代价是跟随慢一帧,肉眼无感。
|
|
||||||
*/
|
|
||||||
function scheduleIndicatorSync(): void {
|
|
||||||
if (indicatorFrame) return;
|
|
||||||
indicatorFrame = requestAnimationFrame(() => {
|
|
||||||
indicatorFrame = 0;
|
|
||||||
syncIndicator();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 滚动菜单时**同步**量一次(不走 rAF 版):滚动本身已经在合成器上跑,
|
|
||||||
* 再隔一帧读数会看到指示器明显落后于菜单。
|
|
||||||
*/
|
|
||||||
function handleMenuScroll(): void {
|
|
||||||
syncIndicator();
|
|
||||||
indicatorFollowing.value = true;
|
|
||||||
window.clearTimeout(scrollSettleTimer);
|
|
||||||
scrollSettleTimer = window.setTimeout(() => {
|
|
||||||
indicatorFollowing.value = false;
|
|
||||||
}, SCROLL_SETTLE_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 绑定/解绑菜单滚动监听(小屏没有侧栏,切回来时要重新绑) */
|
|
||||||
function bindMenuScroller(): void {
|
|
||||||
const next = menuScroller();
|
|
||||||
if (next === scrollerEl) return;
|
|
||||||
scrollerEl?.removeEventListener('scroll', handleMenuScroll);
|
|
||||||
scrollerEl = next;
|
|
||||||
scrollerEl?.addEventListener('scroll', handleMenuScroll, { passive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 首次测量放在 onMounted(而不是 nextTick):子组件先于父组件挂载完成,
|
|
||||||
* 此刻 `.el-menu-item.is-active` 已在文档里,量完再让浏览器首帧绘制 ——
|
|
||||||
* 于是指示器**第一次出现就是正确位置**,不会从左上角滑过去。
|
|
||||||
*/
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 生效权限(`GET /permissions/me`):菜单里的「权限组」要等它回来才显示。
|
|
||||||
// 失败不阻塞布局,带权限要求的入口保持关闭。
|
|
||||||
void auth.ensurePermissions();
|
void auth.ensurePermissions();
|
||||||
syncIndicator();
|
|
||||||
bindMenuScroller();
|
|
||||||
const aside = asideElement();
|
|
||||||
if (aside && typeof ResizeObserver !== 'undefined') {
|
|
||||||
asideObserver = new ResizeObserver(scheduleIndicatorSync);
|
|
||||||
asideObserver.observe(aside);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (indicatorFrame) cancelAnimationFrame(indicatorFrame);
|
|
||||||
indicatorFrame = 0;
|
|
||||||
window.clearTimeout(scrollSettleTimer);
|
|
||||||
scrollerEl?.removeEventListener('scroll', handleMenuScroll);
|
|
||||||
scrollerEl = null;
|
|
||||||
asideObserver?.disconnect();
|
|
||||||
asideObserver = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 路由变化后菜单的 active 类由 Element Plus 更新,等一拍再量
|
|
||||||
watch(
|
|
||||||
() => route.path,
|
|
||||||
() => {
|
|
||||||
void nextTick(syncIndicator);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/** 小屏:侧边栏收进抽屉,由顶栏汉堡按钮唤出 */
|
/** 小屏:侧边栏收进抽屉,由顶栏汉堡按钮唤出 */
|
||||||
const drawerVisible = ref(false);
|
const drawerVisible = ref(false);
|
||||||
|
|
||||||
@@ -356,11 +178,6 @@ watch(
|
|||||||
|
|
||||||
watch(isMobile, (mobile) => {
|
watch(isMobile, (mobile) => {
|
||||||
if (!mobile) drawerVisible.value = false;
|
if (!mobile) drawerVisible.value = false;
|
||||||
// 侧栏是按 v-if 挂载/卸载的:从抽屉切回常驻侧栏后,新的 .el-menu 需要重新绑滚动监听
|
|
||||||
void nextTick(() => {
|
|
||||||
syncIndicator();
|
|
||||||
bindMenuScroller();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -544,7 +361,6 @@ const tourSteps = computed(() => [
|
|||||||
<!-- 桌面/平板:常驻深色侧边栏(折叠成 64px 图标导轨,见样式里的 .ch-nav.is-collapsed) -->
|
<!-- 桌面/平板:常驻深色侧边栏(折叠成 64px 图标导轨,见样式里的 .ch-nav.is-collapsed) -->
|
||||||
<el-aside
|
<el-aside
|
||||||
v-if="!isMobile"
|
v-if="!isMobile"
|
||||||
ref="asideRef"
|
|
||||||
:width="ui.sidebarCollapsed ? 'var(--ch-aside-collapsed-width)' : 'var(--ch-aside-width)'"
|
:width="ui.sidebarCollapsed ? 'var(--ch-aside-collapsed-width)' : 'var(--ch-aside-width)'"
|
||||||
class="layout-aside ch-sidebar ch-nav"
|
class="layout-aside ch-sidebar ch-nav"
|
||||||
:class="{ 'is-collapsed': ui.sidebarCollapsed }"
|
:class="{ 'is-collapsed': ui.sidebarCollapsed }"
|
||||||
@@ -554,23 +370,17 @@ const tourSteps = computed(() => [
|
|||||||
<img class="brand-logo" src="/logo.png" alt="" width="24" height="24" />
|
<img class="brand-logo" src="/logo.png" alt="" width="24" height="24" />
|
||||||
<span class="brand-text">ClassHelper</span>
|
<span class="brand-text">ClassHelper</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- 激活项的滑动指示器:单个药丸在菜单项之间平移,位置由 syncIndicator() 量出来。
|
<ManagementMenu
|
||||||
外层裁剪框贴着**菜单视口**(菜单列可滚动,激活项滚出去时指示器要一起被裁掉),
|
:groups="menuGroups"
|
||||||
pointer-events:none 是必需的 —— 它盖在菜单项上,否则会把点击吃掉。 -->
|
:active="activeMenu"
|
||||||
<div class="nav-indicator-clip" :style="indicatorClipStyle" aria-hidden="true">
|
:collapsed="ui.sidebarCollapsed"
|
||||||
<div class="nav-indicator" :class="{ 'is-following': indicatorFollowing }" :style="indicatorStyle" />
|
@select="handleMenuSelect"
|
||||||
|
@expand="ui.toggleSidebar()"
|
||||||
|
/>
|
||||||
|
<div class="sidebar-foot">
|
||||||
|
<span class="sidebar-status-dot" />
|
||||||
|
<span v-if="!ui.sidebarCollapsed">{{ connectionText }}</span>
|
||||||
</div>
|
</div>
|
||||||
<el-menu :default-active="activeMenu" class="layout-menu" @select="handleMenuSelect">
|
|
||||||
<el-menu-item
|
|
||||||
v-for="item in visibleMenuItems"
|
|
||||||
:key="item.path"
|
|
||||||
:index="item.path"
|
|
||||||
:title="ui.sidebarCollapsed ? item.title : undefined"
|
|
||||||
>
|
|
||||||
<el-icon><component :is="item.icon" /></el-icon>
|
|
||||||
<span>{{ item.title }}</span>
|
|
||||||
</el-menu-item>
|
|
||||||
</el-menu>
|
|
||||||
</el-aside>
|
</el-aside>
|
||||||
|
|
||||||
<el-container class="layout-body">
|
<el-container class="layout-body">
|
||||||
@@ -608,7 +418,12 @@ const tourSteps = computed(() => [
|
|||||||
<Fold v-else />
|
<Fold v-else />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
</button>
|
</button>
|
||||||
<span class="header-title">{{ isMobile ? currentTitle : APP_TITLE }}</span>
|
<span v-if="isMobile" class="header-title">{{ currentTitle }}</span>
|
||||||
|
<el-breadcrumb v-else class="management-breadcrumb" separator="/" aria-label="当前位置">
|
||||||
|
<el-breadcrumb-item :to="{ path: '/dashboard' }">首页</el-breadcrumb-item>
|
||||||
|
<el-breadcrumb-item v-if="currentGroup">{{ currentGroup.title }}</el-breadcrumb-item>
|
||||||
|
<el-breadcrumb-item>{{ currentTitle }}</el-breadcrumb-item>
|
||||||
|
</el-breadcrumb>
|
||||||
<el-tag v-if="!isMobile" :type="connectionType" size="small" effect="light" data-tour="conn-tag">
|
<el-tag v-if="!isMobile" :type="connectionType" size="small" effect="light" data-tour="conn-tag">
|
||||||
<span class="header-conn">{{ connectionText }}</span>
|
<span class="header-conn">{{ connectionText }}</span>
|
||||||
</el-tag>
|
</el-tag>
|
||||||
@@ -628,6 +443,11 @@ const tourSteps = computed(() => [
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
<button class="nav-search" aria-label="搜索页面" @click="searchVisible = true">
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
<span v-if="!isMobile">搜索页面</span>
|
||||||
|
<kbd v-if="!isMobile">Ctrl K</kbd>
|
||||||
|
</button>
|
||||||
<!--
|
<!--
|
||||||
AI 助手快捷入口:与侧栏「AI 助手」菜单项同一道门控。
|
AI 助手快捷入口:与侧栏「AI 助手」菜单项同一道门控。
|
||||||
关怀模式打开时挂 .care-emphasis(字号写在 styles/index.css 的关怀模式块里,
|
关怀模式打开时挂 .care-emphasis(字号写在 styles/index.css 的关怀模式块里,
|
||||||
@@ -754,6 +574,35 @@ const tourSteps = computed(() => [
|
|||||||
</div>
|
</div>
|
||||||
</el-header>
|
</el-header>
|
||||||
|
|
||||||
|
<nav class="workspace-tabs" aria-label="已打开页面">
|
||||||
|
<div class="workspace-tabs-scroll">
|
||||||
|
<div
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.path"
|
||||||
|
class="workspace-tab"
|
||||||
|
:class="{ active: route.path === tab.path }"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
:aria-current="route.path === tab.path ? 'page' : undefined"
|
||||||
|
@click="router.push(tab.fullPath)"
|
||||||
|
>
|
||||||
|
<el-icon><component :is="tab.icon" /></el-icon>
|
||||||
|
{{ tab.title }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="tab.path !== '/dashboard'"
|
||||||
|
class="close-tab"
|
||||||
|
:aria-label="`关闭${tab.title}`"
|
||||||
|
@click="closeTab(tab.path)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="tabs-action" title="关闭其他页签" aria-label="关闭其他页签" @click="closeOtherTabs">
|
||||||
|
<el-icon><FolderOpened /></el-icon>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
<el-main class="layout-main">
|
<el-main class="layout-main">
|
||||||
<!--
|
<!--
|
||||||
这里**刻意不用** `<transition>` 包 router-view。
|
这里**刻意不用** `<transition>` 包 router-view。
|
||||||
@@ -786,15 +635,33 @@ const tourSteps = computed(() => [
|
|||||||
<img class="brand-logo" src="/logo.png" alt="" width="24" height="24" />
|
<img class="brand-logo" src="/logo.png" alt="" width="24" height="24" />
|
||||||
<span class="brand-text">ClassHelper</span>
|
<span class="brand-text">ClassHelper</span>
|
||||||
</div>
|
</div>
|
||||||
<el-menu :default-active="activeMenu" class="layout-menu" @select="handleMenuSelect">
|
<ManagementMenu :groups="menuGroups" :active="activeMenu" @select="handleMenuSelect" />
|
||||||
<el-menu-item v-for="item in visibleMenuItems" :key="item.path" :index="item.path">
|
|
||||||
<el-icon><component :is="item.icon" /></el-icon>
|
|
||||||
<span>{{ item.title }}</span>
|
|
||||||
</el-menu-item>
|
|
||||||
</el-menu>
|
|
||||||
</div>
|
</div>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="searchVisible"
|
||||||
|
title="搜索页面"
|
||||||
|
width="min(560px, 92vw)"
|
||||||
|
class="navigation-search"
|
||||||
|
@closed="searchQuery = ''"
|
||||||
|
>
|
||||||
|
<el-input
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="输入功能名称"
|
||||||
|
clearable
|
||||||
|
autofocus
|
||||||
|
@keydown.enter="searchResults[0] && searchNavigate(searchResults[0].path)"
|
||||||
|
/>
|
||||||
|
<div class="navigation-results">
|
||||||
|
<button v-for="item in searchResults" :key="item.path" @click="searchNavigate(item.path)">
|
||||||
|
<el-icon><component :is="item.icon" /></el-icon>
|
||||||
|
<span>{{ item.title }}</span>
|
||||||
|
<small>{{ menuGroups.find((g) => g.items.some((i) => i.path === item.path))?.title }}</small>
|
||||||
|
</button>
|
||||||
|
<el-empty v-if="!searchResults.length" description="没有匹配的功能" :image-size="64" />
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
<el-dialog v-model="passwordVisible" title="修改密码" width="420px">
|
<el-dialog v-model="passwordVisible" title="修改密码" width="420px">
|
||||||
<el-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-width="90px">
|
<el-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-width="90px">
|
||||||
<el-form-item label="当前密码" prop="currentPassword">
|
<el-form-item label="当前密码" prop="currentPassword">
|
||||||
@@ -1176,3 +1043,5 @@ const tourSteps = computed(() => [
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<style src="../styles/management-shell.css"></style>
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
/* 参照 art-design-pro 的留白、导航层次与工作区结构;沿用现有页面和语义主题令牌。 */
|
||||||
|
:root {
|
||||||
|
--ch-aside-width: 240px;
|
||||||
|
--ch-bg: #f7f8fa;
|
||||||
|
--ch-border: #edf0f4;
|
||||||
|
--el-color-primary: #5d6bfa;
|
||||||
|
--el-color-primary-light-3: #8e98fc;
|
||||||
|
--el-color-primary-light-5: #aeb5fd;
|
||||||
|
--el-color-primary-light-7: #ced2fe;
|
||||||
|
--el-color-primary-light-8: #dfe2fe;
|
||||||
|
--el-color-primary-light-9: #eff0ff;
|
||||||
|
--el-color-primary-dark-2: #4a55c8;
|
||||||
|
}
|
||||||
|
html.dark {
|
||||||
|
--ch-bg: #14161a;
|
||||||
|
--ch-border: #2e323c;
|
||||||
|
--el-color-primary-light-3: #7f8afc;
|
||||||
|
--el-color-primary-light-5: #5963b8;
|
||||||
|
--el-color-primary-light-7: #3c437d;
|
||||||
|
--el-color-primary-light-8: #30365f;
|
||||||
|
--el-color-primary-light-9: #272c48;
|
||||||
|
}
|
||||||
|
.layout .ch-sidebar,
|
||||||
|
.ch-sidebar-drawer.ch-sidebar {
|
||||||
|
background: var(--ch-surface);
|
||||||
|
color: var(--ch-text);
|
||||||
|
border-radius: 0;
|
||||||
|
border-right: 1px solid var(--ch-border);
|
||||||
|
}
|
||||||
|
.layout .brand,
|
||||||
|
.ch-sidebar-drawer .brand {
|
||||||
|
height: 64px;
|
||||||
|
padding: 0 24px;
|
||||||
|
gap: 12px;
|
||||||
|
border-bottom: 0;
|
||||||
|
color: var(--ch-text);
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.layout .brand-text {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.layout .layout-header {
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
border-bottom: 1px solid var(--ch-border);
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
.layout .layout-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.layout .header-left {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.management-breadcrumb {
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.layout .header-left > .el-tag {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.layout .header-right {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.layout .nav-toggle {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.layout .user-chip {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--ch-surface-alt);
|
||||||
|
}
|
||||||
|
.sidebar-foot {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 48px;
|
||||||
|
color: var(--ch-text-muted);
|
||||||
|
border-top: 1px solid var(--ch-border);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.sidebar-status-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
.nav-search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--ch-surface-alt);
|
||||||
|
border: 1px solid var(--ch-border);
|
||||||
|
color: var(--ch-text-muted);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.nav-search kbd {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 10px;
|
||||||
|
border: 1px solid var(--ch-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
background: var(--ch-surface);
|
||||||
|
}
|
||||||
|
.workspace-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 46px;
|
||||||
|
padding: 0 20px;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--ch-surface);
|
||||||
|
border-bottom: 1px solid var(--ch-border);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.workspace-tabs-scroll {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: stretch;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.workspace-tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
gap: 2px;
|
||||||
|
color: var(--ch-text-muted);
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
.workspace-tab.active {
|
||||||
|
border-bottom-color: var(--el-color-primary);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
}
|
||||||
|
.workspace-tab button,
|
||||||
|
.tabs-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 0;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.workspace-tab button.close-tab {
|
||||||
|
font-size: 18px;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.workspace-tab button:hover,
|
||||||
|
.tabs-action:hover {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
.tabs-action {
|
||||||
|
color: var(--ch-text-muted);
|
||||||
|
}
|
||||||
|
.layout .layout-main {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.layout-main .page {
|
||||||
|
max-width: 1800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.layout-main .page-title {
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: -0.4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.layout-main .page-subtitle {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--ch-text-muted);
|
||||||
|
}
|
||||||
|
.layout-main .el-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--ch-border);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.layout-main .el-card__header {
|
||||||
|
padding: 18px 22px;
|
||||||
|
background: transparent;
|
||||||
|
font-weight: 600;
|
||||||
|
border-color: var(--ch-border);
|
||||||
|
}
|
||||||
|
.layout-main .el-table {
|
||||||
|
--el-table-header-bg-color: var(--ch-surface-alt);
|
||||||
|
--el-table-border-color: var(--ch-border);
|
||||||
|
}
|
||||||
|
.layout-main .el-table th.el-table__cell {
|
||||||
|
height: 46px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ch-text-secondary);
|
||||||
|
}
|
||||||
|
.navigation-results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 16px;
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.navigation-results > button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--ch-surface-alt);
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--ch-text);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.navigation-results > button:hover {
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
.navigation-results small {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--ch-text-muted);
|
||||||
|
}
|
||||||
|
.management-menu :focus-visible,
|
||||||
|
.workspace-tabs button:focus-visible,
|
||||||
|
.nav-search:focus-visible {
|
||||||
|
outline: 2px solid var(--el-color-primary);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.nav-search > span,
|
||||||
|
.nav-search > kbd {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.layout .header-right {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.management-breadcrumb .el-breadcrumb__item:nth-child(2) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.layout .layout-header {
|
||||||
|
height: 56px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
.workspace-tabs {
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
.layout-main .page {
|
||||||
|
padding: 16px 12px;
|
||||||
|
}
|
||||||
|
.layout .user-chip {
|
||||||
|
padding: 6px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,32 @@ const realtime = useRealtimeStore();
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const summary = ref<DashboardSummary | null>(null);
|
const summary = ref<DashboardSummary | null>(null);
|
||||||
const unreadCount = ref(0);
|
const unreadCount = ref(0);
|
||||||
|
const todayLabel = new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
weekday: 'long',
|
||||||
|
}).format(new Date());
|
||||||
|
const quickEntries = computed(() =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
path: '/plan-editor',
|
||||||
|
title: '编排课表',
|
||||||
|
hint: '时间表与教学安排',
|
||||||
|
icon: 'Calendar',
|
||||||
|
enabled: auth.can('schedule:read'),
|
||||||
|
},
|
||||||
|
{ path: '/homeworks', title: '发布作业', hint: '布置并追踪完成情况', icon: 'Notebook', enabled: true },
|
||||||
|
{ path: '/notifications', title: '通知与叫人', hint: '实时送达教室', icon: 'Bell', enabled: true },
|
||||||
|
{
|
||||||
|
path: '/clients',
|
||||||
|
title: '客户端',
|
||||||
|
hint: '设备状态与维护',
|
||||||
|
icon: 'Monitor',
|
||||||
|
enabled: auth.can('client:read'),
|
||||||
|
},
|
||||||
|
].filter((entry) => entry.enabled),
|
||||||
|
);
|
||||||
|
|
||||||
const statCards = computed(() => {
|
const statCards = computed(() => {
|
||||||
const data = summary.value;
|
const data = summary.value;
|
||||||
@@ -100,6 +126,26 @@ function openNotification(item: NotificationDto): void {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section class="dashboard-welcome">
|
||||||
|
<div>
|
||||||
|
<span class="welcome-eyebrow">{{ todayLabel }}</span>
|
||||||
|
<h3>{{ auth.displayName }},欢迎回来</h3>
|
||||||
|
<p>教学安排、班级动态与设备运行,在这里集中处理。</p>
|
||||||
|
</div>
|
||||||
|
<div class="welcome-symbol" aria-hidden="true">
|
||||||
|
<el-icon><School /></el-icon>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div class="dashboard-shortcuts" aria-label="常用功能">
|
||||||
|
<button v-for="entry in quickEntries" :key="entry.path" @click="router.push(entry.path)">
|
||||||
|
<el-icon><component :is="entry.icon" /></el-icon>
|
||||||
|
<span>
|
||||||
|
<strong>{{ entry.title }}</strong>
|
||||||
|
<small>{{ entry.hint }}</small>
|
||||||
|
</span>
|
||||||
|
<el-icon><Right /></el-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="stat-grid">
|
<div class="stat-grid">
|
||||||
<StatCard
|
<StatCard
|
||||||
v-for="(card, index) in statCards"
|
v-for="(card, index) in statCards"
|
||||||
@@ -164,6 +210,113 @@ function openNotification(item: NotificationDto): void {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.dashboard-welcome {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 26px 30px;
|
||||||
|
border: 1px solid var(--el-color-primary-light-8);
|
||||||
|
border-radius: 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background: linear-gradient(110deg, var(--el-color-primary-light-9), var(--ch-surface));
|
||||||
|
}
|
||||||
|
.welcome-eyebrow {
|
||||||
|
color: var(--ch-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.dashboard-welcome h3 {
|
||||||
|
margin: 10px 0;
|
||||||
|
color: var(--ch-text);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.dashboard-welcome p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ch-text-secondary);
|
||||||
|
}
|
||||||
|
.welcome-symbol {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 84px;
|
||||||
|
height: 84px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: var(--el-color-primary-light-8);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-size: 44px;
|
||||||
|
transform: rotate(-8deg);
|
||||||
|
}
|
||||||
|
.dashboard-shortcuts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.dashboard-shortcuts button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 18px;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--ch-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--ch-surface);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.dashboard-shortcuts button:hover {
|
||||||
|
border-color: var(--el-color-primary-light-5);
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
}
|
||||||
|
.dashboard-shortcuts button > .el-icon:first-child {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
.dashboard-shortcuts button > .el-icon:last-child {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.dashboard-shortcuts span {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.dashboard-shortcuts strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--ch-text);
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dashboard-shortcuts small {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ch-text-muted);
|
||||||
|
margin-top: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.dashboard-shortcuts {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.dashboard-welcome {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.dashboard-welcome h3 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.welcome-symbol {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.dashboard-shortcuts button {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
.card-header {
|
.card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -507,7 +507,7 @@ onUnmounted(() => {
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="page-title">作业发布</h2>
|
<h2 class="page-title">作业发布</h2>
|
||||||
<p class="page-subtitle">发布后立即通过 WebSocket 广播到学生客户端(homework:new)</p>
|
<p class="page-subtitle">按班级和课程发布作业,实时送达教室并查看完成情况</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<el-select
|
<el-select
|
||||||
|
|||||||
@@ -493,6 +493,45 @@ async function main() {
|
|||||||
`出现=${tour?.appeared ?? '-'} 跳过后关闭=${tour?.closed ?? '-'}`,
|
`出现=${tour?.appeared ?? '-'} 跳过后关闭=${tour?.closed ?? '-'}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const navigation = await win.webContents.executeJavaScript(`(async () => {
|
||||||
|
const pause = () => new Promise(r => setTimeout(r,250));
|
||||||
|
const groups = [...document.querySelectorAll('.management-menu > .el-sub-menu')];
|
||||||
|
const targets = ['/notifications','/homeworks'];
|
||||||
|
let visited = true;
|
||||||
|
for (const path of targets) {
|
||||||
|
const item=document.querySelector('.management-menu [data-path="'+path+'"]');
|
||||||
|
if (!item) { visited=false; continue; }
|
||||||
|
const parent=item.closest('.el-sub-menu');
|
||||||
|
if (!parent.classList.contains('is-opened')) parent.querySelector('.el-sub-menu__title').click();
|
||||||
|
await pause(); item.click(); await pause();
|
||||||
|
visited = visited && location.pathname === path && !!document.querySelector('.management-breadcrumb');
|
||||||
|
}
|
||||||
|
const tabs=document.querySelectorAll('.workspace-tab').length;
|
||||||
|
const active=document.querySelector('.workspace-tab.active')?.textContent.includes('作业');
|
||||||
|
document.querySelector('[aria-label="搜索页面"]').click(); await pause();
|
||||||
|
const input=document.querySelector('.navigation-search input');
|
||||||
|
input.value='通知';input.dispatchEvent(new Event('input',{bubbles:true}));await pause();
|
||||||
|
const result=document.querySelector('.navigation-results button');result?.click();await pause();
|
||||||
|
const searched=location.pathname==='/notifications';
|
||||||
|
const close=document.querySelector('[aria-label="关闭通知发布"]');close?.click();await pause();
|
||||||
|
const closed=location.pathname==='/homeworks'&&!document.querySelector('[aria-label="关闭通知发布"]');
|
||||||
|
document.querySelector('[data-test="nav-collapse-toggle"]').click();await pause();
|
||||||
|
groups[0].querySelector('.management-group-title').click();await pause();
|
||||||
|
const railExpanded=!document.querySelector('.layout-aside.is-collapsed')&&groups[0].classList.contains('is-opened');
|
||||||
|
const current=document.querySelector('.management-menu [data-path="/homeworks"]')?.closest('.el-sub-menu');
|
||||||
|
if(current&&!current.classList.contains('is-opened')) current.querySelector('.el-sub-menu__title').click();await pause();
|
||||||
|
|
||||||
|
return {ok:groups.length>=2&&visited&&tabs>=3&&active&&searched&&closed&&railExpanded,railExpanded,groups:groups.length,tabs,visited,active,searched,closed};
|
||||||
|
})()`);
|
||||||
|
record('二级菜单、面包屑、页签切换关闭和权限内搜索均可用', navigation.ok, JSON.stringify(navigation));
|
||||||
|
if (process.env.UI_SMOKE_SHOTS_DIR) {
|
||||||
|
fs.mkdirSync(process.env.UI_SMOKE_SHOTS_DIR, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(process.env.UI_SMOKE_SHOTS_DIR, 'desktop-management.png'),
|
||||||
|
(await win.webContents.capturePage()).toPNG(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 4. 逐一点击侧边栏菜单
|
// 4. 逐一点击侧边栏菜单
|
||||||
const visited = [];
|
const visited = [];
|
||||||
const failures = [];
|
const failures = [];
|
||||||
|
|||||||
Reference in new issue
Block a user