feat(ui): 补齐课程提醒进度并内嵌编辑器与重构后台导航
This commit is contained in:
1 parent
3379eb975c
commit
536379036a
34 files changed
+1554
-450
No files matched your search
@@ -182,6 +182,29 @@ LessonReminderTracker 只读取服务端已解析的当天 ScheduleDto 投影,
|
||||
课前 60 秒开始:3 秒强调遮罩后显示实时秒数和课程时间,持续到上课;上课遮罩 5 秒后退出。
|
||||
下课先提示 2 秒,再预告下节课程 10 秒;当天最后一课下课只提示 5 秒。不补弹已经过去的上下课事件。
|
||||
刷新同一课表不会重置遮罩,临时换课立即更新名称,移除课程或退出账号会取消提醒。
|
||||
提醒是独立本机状态,不写入教师通知、未读计数或通知队列;已经打开的通知详情和紧急提示优先展示。
|
||||
提醒是独立本机状态,不写入教师通知、未读计数或通知队列;用户主动打开的通知详情和紧急提示优先展示,自动补弹的普通通知不再挡住上下课提醒。
|
||||
经典与自定义两种布局使用同一个 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 最小的行,全部原行仍保留;旧数据未记录的群组、临时层和时间点名称无法恢复。
|
||||
|
||||
实施选择、问题根因与实测结果见 [实施记录](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">
|
||||
倒计时目标
|
||||
<select v-model="node.settings.target">
|
||||
<option value="auto">自动:课中距下课,课间距上课</option>
|
||||
<option value="next">下一节课</option>
|
||||
<option value="end">本节下课</option>
|
||||
<option value="school">放学</option>
|
||||
</select>
|
||||
</label>
|
||||
@@ -95,6 +97,10 @@ const fonts = {
|
||||
<span>显示课程时间</span>
|
||||
<input type="checkbox" v-model="node.settings.showPeriod" />
|
||||
</label>
|
||||
<label class="toggle-field">
|
||||
<span>显示上课进度条</span>
|
||||
<input type="checkbox" v-model="node.settings.showProgress" />
|
||||
</label>
|
||||
</template>
|
||||
<template v-else-if="['group', 'stack', 'slide', 'rolling'].includes(node.kind)">
|
||||
<label class="field">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, type CSSProperties } from 'vue';
|
||||
import {
|
||||
ISLAND_COMPONENT_LABELS,
|
||||
resolveClassStatus,
|
||||
type IslandComponentNode,
|
||||
type IslandState,
|
||||
type ScheduleDto,
|
||||
@@ -23,16 +24,35 @@ function select(event: MouseEvent) {
|
||||
emit('select', props.node.id);
|
||||
}
|
||||
const container = computed(() => ['group', 'stack', 'slide', 'rolling'].includes(props.node.kind));
|
||||
const current = computed(() => {
|
||||
const d = new Date(props.now),
|
||||
t = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
return props.schedules.find((s) => s.startTime <= t && s.endTime > t);
|
||||
});
|
||||
const next = computed(() => {
|
||||
const d = new Date(props.now),
|
||||
t = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
return props.schedules.find((s) => s.startTime > t);
|
||||
});
|
||||
const today = computed(() =>
|
||||
props.schedules
|
||||
.filter((s) => s.dayOfWeek === (new Date(props.now).getDay() || 7))
|
||||
.sort((a, b) => a.startTime.localeCompare(b.startTime)),
|
||||
);
|
||||
const status = computed(() => resolveClassStatus(today.value, new Date(props.now)));
|
||||
const current = computed(() => today.value.find((s) => s.id === status.value.current?.scheduleId));
|
||||
const next = computed(() => today.value.find((s) => s.id === status.value.next?.scheduleId));
|
||||
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(
|
||||
() =>
|
||||
!props.editing &&
|
||||
@@ -75,21 +95,27 @@ const content = computed(() => {
|
||||
.replaceAll('{course}', current.value?.course?.name || '暂无课程')
|
||||
: n.settings.text;
|
||||
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 '今日课程已结束';
|
||||
const [h, m] = time.split(':').map(Number);
|
||||
const end = new Date(d);
|
||||
end.setHours(h!, m!, 0, 0);
|
||||
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 '';
|
||||
});
|
||||
const lessons = computed(() =>
|
||||
props.node.settings.display === 'today'
|
||||
? props.schedules.slice(0, props.node.settings.count)
|
||||
: [props.node.settings.display === 'next' ? next.value : current.value].filter((s): s is ScheduleDto =>
|
||||
Boolean(s),
|
||||
? today.value.slice(0, props.node.settings.count)
|
||||
: [props.node.settings.display === 'next' ? next.value : current.value || next.value].filter(
|
||||
(s): s is ScheduleDto => Boolean(s),
|
||||
),
|
||||
);
|
||||
const style = computed<CSSProperties>(() => {
|
||||
@@ -166,8 +192,22 @@ const style = computed<CSSProperties>(() => {
|
||||
<div v-for="s in lessons" :key="s.id" class="lesson-line">
|
||||
<strong>{{ s.course?.name }}</strong>
|
||||
<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>
|
||||
<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 v-else-if="['notification', 'homework', 'call'].includes(node.kind)">
|
||||
<slot v-if="node.id === alertNodeId" name="alerts" />
|
||||
@@ -197,7 +237,7 @@ const style = computed<CSSProperties>(() => {
|
||||
.countdown {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.schedule > div {
|
||||
.schedule > .lesson-line {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@@ -215,9 +255,29 @@ const style = computed<CSSProperties>(() => {
|
||||
font-variant-numeric: tabular-nums;
|
||||
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;
|
||||
}
|
||||
.lesson-remaining {
|
||||
font-size: var(--secondary, 14px);
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.separator > span {
|
||||
display: block;
|
||||
width: 1px;
|
||||
|
||||
@@ -22,6 +22,7 @@ const props = defineProps<{
|
||||
editing: boolean;
|
||||
appearance: IslandAppearance;
|
||||
schedules: ScheduleDto[];
|
||||
embedded?: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
save: [settings: IslandLayoutSettings];
|
||||
@@ -115,6 +116,10 @@ function switchScheme() {
|
||||
const now = ref(Date.now()),
|
||||
canvasScale = ref(1),
|
||||
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>(),
|
||||
inner = ref<HTMLElement>();
|
||||
const viewport = ref({ width: 0, height: 0 });
|
||||
@@ -358,6 +363,21 @@ let observer: ResizeObserver | undefined,
|
||||
function measure() {
|
||||
clearTimeout(measureTimer);
|
||||
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) {
|
||||
// 内部组件的自然尺寸决定整个岛体;提醒不再拥有另一层岛壳。
|
||||
const rect = inner.value.getBoundingClientRect(),
|
||||
@@ -387,6 +407,8 @@ onMounted(() => {
|
||||
clock = setInterval(() => (now.value = Date.now()), 1000);
|
||||
observer = new ResizeObserver(measure);
|
||||
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));
|
||||
measure();
|
||||
});
|
||||
@@ -400,6 +422,7 @@ onUnmounted(() => {
|
||||
<template>
|
||||
<div
|
||||
:class="editing ? 'layout-editor' : 'custom-window'"
|
||||
:data-embedded="embedded || undefined"
|
||||
@pointerdown.capture="onPointerDown"
|
||||
@pointermove="panMove"
|
||||
@pointerup="
|
||||
@@ -482,7 +505,7 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main :class="{ canvas: editing }" @pointerdown="panStart">
|
||||
<main ref="canvas" :class="{ canvas: editing }" @pointerdown="panStart">
|
||||
<div v-if="editing" class="canvas-toolbar">
|
||||
<nav class="surface-tabs" aria-label="编辑自定义灵动岛内容">
|
||||
<button :class="{ active: editingSurface === 'compact' }" @click="setSurface('compact')">
|
||||
@@ -513,8 +536,9 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="previewStage"
|
||||
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
|
||||
ref="shell"
|
||||
|
||||
@@ -254,8 +254,8 @@ function measure() {
|
||||
}
|
||||
function resizeCanvas(width = innerWidth, height = innerHeight) {
|
||||
available.value = {
|
||||
width: Math.max(40, width - 2 * ISLAND_SHADOW_PAD - 4),
|
||||
height: Math.max(40, height - 2 * ISLAND_SHADOW_PAD - 4),
|
||||
width: Math.max(40, Math.min(width, innerWidth) - 2 * ISLAND_SHADOW_PAD - 4),
|
||||
height: Math.max(40, Math.min(height, innerHeight) - 2 * ISLAND_SHADOW_PAD - 4),
|
||||
};
|
||||
measure();
|
||||
}
|
||||
@@ -263,6 +263,7 @@ const resize = () => resizeCanvas();
|
||||
let stopViewport: (() => void) | undefined;
|
||||
function visibility() {
|
||||
visible.value = !document.hidden;
|
||||
resize();
|
||||
void nextTick(reportHit);
|
||||
}
|
||||
// 不深度监听整份状态;只在目标内容/几何变化时测量,时钟每秒只改文本。
|
||||
@@ -285,7 +286,11 @@ watch(
|
||||
onMounted(() => {
|
||||
resize();
|
||||
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 (details.value) observer.observe(details.value);
|
||||
if (lessonSource.value) observer.observe(lessonSource.value);
|
||||
@@ -327,6 +332,7 @@ onUnmounted(() => {
|
||||
call: state.active?.kind === 'call',
|
||||
'resident-separated': scheme.separated && !expanded && !lesson,
|
||||
'resident-lesson': !!lesson,
|
||||
'message-surface': expanded && messages.length > 0 && !lesson,
|
||||
}"
|
||||
:style="style"
|
||||
:role="expanded ? 'region' : 'button'"
|
||||
|
||||
@@ -389,6 +389,7 @@ const islandClass = computed(() => ({
|
||||
'hidden-placeholder':
|
||||
mode.value === 'hidden' && !appearance.value.idleSliver && !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,
|
||||
urgent: isUrgent.value,
|
||||
call: isCall.value,
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, onUnmounted, ref, shallowRef } from 'vue';
|
||||
import { nextTick, onMounted, onUnmounted, ref } from 'vue';
|
||||
import {
|
||||
defaultIslandLayouts,
|
||||
DEFAULT_ISLAND_APPEARANCE,
|
||||
type IslandAppearance,
|
||||
type IslandLayoutSettings,
|
||||
type IslandState,
|
||||
type ScheduleDto,
|
||||
} from '@classhelper/shared';
|
||||
import FixedIsland from './IslandApp.vue';
|
||||
import CustomIsland from './CustomIsland.vue';
|
||||
import './custom-island.css';
|
||||
import CustomIslandRuntime from './CustomIslandRuntime.vue';
|
||||
const settings = ref(defaultIslandLayouts()),
|
||||
editing = ref(false),
|
||||
ready = ref(false);
|
||||
const schedules = ref<ScheduleDto[]>([]);
|
||||
const appearance = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
|
||||
const nativeVisible = ref(false);
|
||||
const alertHost = shallowRef<HTMLElement | null>(null);
|
||||
const resident = () => settings.value.layout === 'custom' && !editing.value;
|
||||
const resident = () => settings.value.layout === 'custom';
|
||||
const state = ref<IslandState>({
|
||||
mode: 'hidden',
|
||||
active: null,
|
||||
@@ -30,13 +27,6 @@ const state = ref<IslandState>({
|
||||
maxCardHeight: 1024,
|
||||
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>;
|
||||
onUnmounted(() => clearInterval(heartbeat));
|
||||
onMounted(async () => {
|
||||
@@ -44,7 +34,6 @@ onMounted(async () => {
|
||||
const bridge = window.island;
|
||||
if (bridge) {
|
||||
bridge.onLayout((value) => (settings.value = value));
|
||||
bridge.onEditing((value) => (editing.value = value));
|
||||
bridge.onSchedules((value) => (schedules.value = value));
|
||||
bridge.onState((value) => (state.value = value));
|
||||
bridge.onAppearance((value) => (appearance.value = value));
|
||||
@@ -54,7 +43,6 @@ onMounted(async () => {
|
||||
settings.value = await bridge.getLayout();
|
||||
schedules.value = await bridge.getSchedules();
|
||||
state.value = await bridge.getState();
|
||||
editing.value = await bridge.getEditing();
|
||||
}
|
||||
await nextTick();
|
||||
ready.value = true;
|
||||
@@ -69,26 +57,5 @@ onMounted(async () => {
|
||||
:appearance="appearance"
|
||||
:native-visible="nativeVisible"
|
||||
/>
|
||||
<CustomIsland
|
||||
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>
|
||||
<FixedIsland v-if="ready" v-show="!resident()" :suspended="resident()" />
|
||||
</template>
|
||||
@@ -12,17 +12,39 @@ const root = ref<HTMLElement>();
|
||||
const width = ref(480);
|
||||
const mask = computed(() => props.now < props.reminder.maskUntil);
|
||||
const title = computed(
|
||||
() => ({ prepare: '即将上课', 'on-class': '上课', break: '课间休息' })[props.reminder.kind],
|
||||
() =>
|
||||
({
|
||||
prepare: '即将上课',
|
||||
'on-class': '上课',
|
||||
break: '课间休息',
|
||||
ending: '即将下课',
|
||||
'in-class': '正在上课',
|
||||
})[props.reminder.kind],
|
||||
);
|
||||
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(() =>
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
秒
|
||||
</span>
|
||||
<span v-else class="ci-countdown">课间休息</span>
|
||||
<span class="ci-lesson-course">
|
||||
<span>下节课是:</span>
|
||||
<span>
|
||||
{{
|
||||
reminder.kind === 'ending' || reminder.kind === 'on-class' || reminder.kind === 'in-class'
|
||||
? '本节课:'
|
||||
: '下节课是:'
|
||||
}}
|
||||
</span>
|
||||
<strong>{{ course }}</strong>
|
||||
<small>{{ time }}</small>
|
||||
</span>
|
||||
</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">
|
||||
<polygon v-for="(points, i) in polygons" :key="i" :points="points" />
|
||||
</svg>
|
||||
|
||||
@@ -80,6 +80,10 @@ body {
|
||||
--wn-bg: #0b0b0f;
|
||||
--wn-border: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
/* 阅读老师消息时使用实色底,玻璃风格也不透出桌面。 */
|
||||
.island-card.message-surface {
|
||||
--wn-bg: #202020;
|
||||
}
|
||||
|
||||
.island-card.hidden-placeholder {
|
||||
visibility: hidden;
|
||||
|
||||
@@ -250,3 +250,6 @@
|
||||
fill: transparent;
|
||||
stroke: none;
|
||||
}
|
||||
.resident-window .resident-card.message-surface {
|
||||
--wn-bg: #202020;
|
||||
}
|
||||
@@ -227,6 +227,13 @@ if (!gotLock) {
|
||||
mainWindow.show();
|
||||
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) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
|
||||
@@ -171,7 +171,7 @@ class IslandController {
|
||||
!this.previewId &&
|
||||
!(
|
||||
this.state.active &&
|
||||
(this.state.mode === 'expanded' ||
|
||||
(this.explicitShowId === this.state.active.id ||
|
||||
(this.state.active.priority === 'URGENT' && this.state.mode !== 'hidden'))
|
||||
),
|
||||
);
|
||||
@@ -183,7 +183,13 @@ class IslandController {
|
||||
this.refreshLessonReminder();
|
||||
}
|
||||
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;
|
||||
this.lessonCue = next;
|
||||
// 触摸窗口先放得下新横条,再下发内容;混合宽高变化也只能增大画布。
|
||||
@@ -209,6 +215,15 @@ class IslandController {
|
||||
private boundsFrame: number | null = null;
|
||||
/** 外观设置(设置页可调,主进程持久化) */
|
||||
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;
|
||||
@@ -289,6 +304,7 @@ class IslandController {
|
||||
return this.editingLayout;
|
||||
}
|
||||
syncCustomLayout(): void {
|
||||
this.refreshLessonReminder();
|
||||
if (this.residentEnabled !== this.residentWasEnabled) {
|
||||
this.residentFaded = false;
|
||||
this.residentWasEnabled = this.residentEnabled;
|
||||
@@ -317,42 +333,12 @@ class IslandController {
|
||||
this.syncWindow();
|
||||
}, 320);
|
||||
}
|
||||
private editLayoutHandler: (() => void) | null = null;
|
||||
setEditLayoutHandler(handler: () => void): void {
|
||||
this.editLayoutHandler = handler;
|
||||
}
|
||||
editLayout(enabled = true): void {
|
||||
if (enabled) this.residentFaded = false;
|
||||
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();
|
||||
}
|
||||
}
|
||||
if (enabled) this.editLayoutHandler?.();
|
||||
}
|
||||
reportCustomSize(value: { width: number; height: number }): void {
|
||||
if (
|
||||
@@ -1393,7 +1379,7 @@ class IslandController {
|
||||
if (this.residentEnabled) this.applyFocusable();
|
||||
this.updateResidentPointer();
|
||||
this.win.setOpacity(
|
||||
this.appearance.opacity * (this.residentFaded ? CLASSISLAND_ISLAND_METRICS.fadedOpacity : 1),
|
||||
this.displayOpacity * (this.residentFaded ? CLASSISLAND_ISLAND_METRICS.fadedOpacity : 1),
|
||||
);
|
||||
if (
|
||||
!this.win.isVisible() &&
|
||||
@@ -1408,7 +1394,7 @@ class IslandController {
|
||||
if (this.lessonShowing) {
|
||||
this.cancelFade();
|
||||
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'))
|
||||
this.win.showInactive();
|
||||
this.syncHitFromCursor();
|
||||
@@ -1441,7 +1427,7 @@ class IslandController {
|
||||
this.setInteractive(false, '隐藏态');
|
||||
if (this.appearance.idleSliver) {
|
||||
this.cancelFade();
|
||||
this.win.setOpacity(this.appearance.opacity);
|
||||
this.win.setOpacity(this.displayOpacity);
|
||||
if (!this.win.isVisible()) this.win.showInactive();
|
||||
// 细缝也要"可见即可点":按真实光标位置决定是否接收鼠标
|
||||
this.syncHitFromCursor();
|
||||
@@ -1454,8 +1440,8 @@ class IslandController {
|
||||
// 岛应该可见:**必须**先取消可能还在跑的淡出 —— 否则那次淡出会继续把透明度降到 0
|
||||
// 并 hide() 掉窗口,造成"状态是胶囊/展开、窗口却被隐藏"的错位(岛看着在、点不动或直接消失)。
|
||||
this.cancelFade();
|
||||
if (this.win.getOpacity() !== this.appearance.opacity) {
|
||||
this.win.setOpacity(this.appearance.opacity);
|
||||
if (this.win.getOpacity() !== this.displayOpacity) {
|
||||
this.win.setOpacity(this.displayOpacity);
|
||||
}
|
||||
if (!this.win.isVisible()) {
|
||||
// 窗口位置由固定包围盒决定(applyWindowLayout),这里只负责显示
|
||||
@@ -1628,7 +1614,7 @@ class IslandController {
|
||||
|
||||
private fadeIn(): void {
|
||||
if (!this.win || this.win.isDestroyed()) return;
|
||||
const target = this.appearance.opacity;
|
||||
const target = this.displayOpacity;
|
||||
if (!this.appearance.animations) {
|
||||
this.fadeToken += 1;
|
||||
this.win.setOpacity(target);
|
||||
@@ -1721,7 +1707,7 @@ class IslandController {
|
||||
this.win.setAlwaysOnTop(this.appearance.alwaysOnTop, 'screen-saver');
|
||||
if (this.win.isVisible()) {
|
||||
// 显示中:立即应用透明度(隐藏时由 fadeIn 负责)
|
||||
this.win.setOpacity(this.appearance.opacity);
|
||||
this.win.setOpacity(this.displayOpacity);
|
||||
}
|
||||
}
|
||||
this.emitAppearance();
|
||||
@@ -1828,7 +1814,7 @@ class IslandController {
|
||||
!overControl;
|
||||
if (faded === this.residentFaded) return;
|
||||
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 {
|
||||
dayKeyLocal,
|
||||
resolveClassStatus,
|
||||
DEFAULT_LESSON_REMINDERS,
|
||||
type LessonReminderSettings,
|
||||
type IslandLessonReminder,
|
||||
type ScheduleDto,
|
||||
} from '@classhelper/shared';
|
||||
@@ -13,7 +15,11 @@ import {
|
||||
export class LessonReminderTracker {
|
||||
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 keyOf = (s: ScheduleDto, kind: IslandLessonReminder['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];
|
||||
let kind: IslandLessonReminder['kind'] | null = null;
|
||||
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';
|
||||
lesson = current;
|
||||
} else if (
|
||||
settings.breakEnabled &&
|
||||
!current &&
|
||||
ended &&
|
||||
at - timeAt(ended.endTime) < (next ? 12_000 : 5_000) &&
|
||||
@@ -43,9 +50,24 @@ export class LessonReminderTracker {
|
||||
) {
|
||||
kind = 'break';
|
||||
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';
|
||||
lesson = next;
|
||||
} else if (current && settings.classProgressEnabled) {
|
||||
kind = 'in-class';
|
||||
lesson = current;
|
||||
}
|
||||
if (!lesson || !kind) return (this.current = null);
|
||||
const id = keyOf(lesson, kind);
|
||||
@@ -60,13 +82,23 @@ export class LessonReminderTracker {
|
||||
endTime: lesson.endTime,
|
||||
startedAt: entered,
|
||||
maskUntil:
|
||||
kind === 'prepare'
|
||||
kind === 'ending' || kind === 'in-class'
|
||||
? entered
|
||||
: kind === 'prepare'
|
||||
? Math.min(start, entered + 3000)
|
||||
: kind === 'break' && next
|
||||
? entered + 2000
|
||||
: entered + 5000,
|
||||
endsAt: kind === 'prepare' ? start : kind === 'on-class' ? start + 5000 : end + (next ? 12_000 : 5000),
|
||||
endsAt:
|
||||
kind === 'ending' || kind === 'in-class'
|
||||
? end
|
||||
: kind === 'prepare'
|
||||
? start
|
||||
: kind === 'on-class'
|
||||
? start + 5000
|
||||
: end + (next ? 12_000 : 5000),
|
||||
lessonStartsAt: start,
|
||||
lessonEndsAt: end,
|
||||
nextCourseName: kind === 'break' ? next?.course?.name : undefined,
|
||||
nextStartTime: kind === 'break' ? next?.startTime : undefined,
|
||||
nextEndTime: kind === 'break' ? next?.endTime : undefined,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import { BrowserWindow } from 'electron';
|
||||
import path from 'node:path';
|
||||
import { island } from './island.js';
|
||||
import { getConfig, saveConfig } from './config.js';
|
||||
@@ -11,6 +12,10 @@ export async function verifyLayoutSmoke(
|
||||
await island.init(null);
|
||||
island.setTestInputPassthrough(true);
|
||||
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));
|
||||
try {
|
||||
await pause();
|
||||
@@ -27,27 +32,40 @@ export async function verifyLayoutSmoke(
|
||||
);
|
||||
});
|
||||
await check('自定义编辑模式可进入并添加组件行', async () => {
|
||||
island.editLayout();
|
||||
saveConfig({ islandLayout: { ...defaultIslandLayouts(), layout: 'custom' } });
|
||||
island.syncCustomLayout();
|
||||
await pause();
|
||||
return win.webContents.executeJavaScript(
|
||||
editingBounds = win.getBounds();
|
||||
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 pause();
|
||||
await win.webContents.executeJavaScript(`(() => {
|
||||
const dragGeometry = await editor.webContents.executeJavaScript(`(() => {
|
||||
const handle=document.querySelector('.node-handle[data-kind=clock]');
|
||||
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;
|
||||
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('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();
|
||||
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 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})); })()`,
|
||||
);
|
||||
await pause();
|
||||
@@ -58,38 +76,58 @@ export async function verifyLayoutSmoke(
|
||||
);
|
||||
});
|
||||
await check('组件设置按内容外观高级切换,整体外观单独显示', async () => {
|
||||
const initial = await win.webContents.executeJavaScript(`(() => {
|
||||
const initial = await editor.webContents.executeJavaScript(`(() => {
|
||||
const panel=document.querySelector('.settings');
|
||||
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();
|
||||
const appearance = await win.webContents.executeJavaScript(`(() => {
|
||||
const appearance = await editor.webContents.executeJavaScript(`(() => {
|
||||
const panel=document.querySelector('.settings');
|
||||
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();
|
||||
const advanced = await win.webContents.executeJavaScript(`(() => {
|
||||
const advanced = await editor.webContents.executeJavaScript(`(() => {
|
||||
const panel=document.querySelector('.settings');
|
||||
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();
|
||||
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) {
|
||||
fs.mkdirSync(process.env.ISLAND_SHOTS_DIR, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(process.env.ISLAND_SHOTS_DIR, 'custom-editor.png'),
|
||||
(await win.webContents.capturePage()).toPNG(),
|
||||
(await editor.webContents.capturePage()).toPNG(),
|
||||
);
|
||||
}
|
||||
await check('退出编辑后配置可从 getConfig 读回', async () => {
|
||||
island.editLayout(false);
|
||||
await editor.webContents.executeJavaScript('document.querySelector(".done-button")?.click()');
|
||||
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 pause();
|
||||
return win.webContents.executeJavaScript(`(() => {
|
||||
@@ -145,6 +183,8 @@ export async function verifyLayoutSmoke(
|
||||
});
|
||||
await verifyResidentSmoke(check);
|
||||
} finally {
|
||||
if (editor && !editor.isDestroyed())
|
||||
await editor.webContents.executeJavaScript('window.__classhelperSmoke__.islandEditorSmoke(false)');
|
||||
saveConfig({ islandLayout: original });
|
||||
island.syncCustomLayout();
|
||||
win.hide();
|
||||
|
||||
@@ -110,7 +110,7 @@ export async function verifyResidentSmoke(
|
||||
changed?.courseName === '临时换课' &&
|
||||
changed.maskUntil === prepare.maskUntil &&
|
||||
onClass?.kind === 'on-class' &&
|
||||
!noLate &&
|
||||
noLate?.kind === 'in-class' &&
|
||||
breaking?.kind === 'break' &&
|
||||
breaking.nextCourseName === '语文' &&
|
||||
!after &&
|
||||
@@ -119,6 +119,32 @@ export async function verifyResidentSmoke(
|
||||
!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 = [
|
||||
islandNode('clock', 'resident-clock'),
|
||||
islandNode('separator', 'resident-separator'),
|
||||
@@ -557,7 +583,59 @@ export async function verifyResidentSmoke(
|
||||
}
|
||||
await setTime(9, 0, 6);
|
||||
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);
|
||||
island.setClassState({ inClass: false });
|
||||
await pause(700);
|
||||
@@ -671,36 +749,60 @@ export async function verifyResidentSmoke(
|
||||
island.setTouchMode(false);
|
||||
}
|
||||
});
|
||||
await check('常驻和无通知展开内容可独立编辑并持久化', async () => {
|
||||
await check('编辑入口交给主窗口,浮岛不会变成额外的全屏编辑窗口', async () => {
|
||||
const before = win.getBounds();
|
||||
let opened = false;
|
||||
island.setEditLayoutHandler(() => {
|
||||
opened = true;
|
||||
});
|
||||
island.editLayout();
|
||||
await pause();
|
||||
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)));
|
||||
await pause(100);
|
||||
return (
|
||||
independent &&
|
||||
roundtrip.residentEnabled &&
|
||||
roundtrip.schemes[0]!.expandedRows[0]!.nodes.some((n) => n.settings.text === '放学前检查')
|
||||
opened &&
|
||||
!island.getEditingLayout() &&
|
||||
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 () => {
|
||||
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
|
||||
saveConfig({ islandLayout: defaultIslandLayouts() });
|
||||
|
||||
@@ -2161,7 +2161,7 @@ async function runIslandChecks(
|
||||
}
|
||||
return count;
|
||||
};
|
||||
const collapseResidue = { shots: 0, max: 0 };
|
||||
const collapseResidue = { shots: 0, stableShots: 0, max: 0 };
|
||||
await islandWindow?.webContents.executeJavaScript(
|
||||
`(() => {
|
||||
const button = document.querySelector('.island-card.expanded .icon-btn');
|
||||
@@ -2170,23 +2170,46 @@ async function runIslandChecks(
|
||||
})()`,
|
||||
);
|
||||
for (let attempt = 0; attempt < 22; attempt += 1) {
|
||||
const before = await readIslandGeometryFrom(islandWindow!);
|
||||
const image = await grabScreen();
|
||||
const geometry = await readIslandGeometryFrom(islandWindow!);
|
||||
if (image && geometry) {
|
||||
if (image && geometry && before) {
|
||||
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 shotScale = image.getSize().width / Math.max(1, displayForCollapse.size.width);
|
||||
collapseResidue.max = Math.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);
|
||||
}
|
||||
record(
|
||||
'触摸模式收起:窗口带内不出现残影(垫底板逐帧检查,窗口让出的区域不留上一帧)',
|
||||
collapseResidue.shots >= 4 && collapseResidue.max <= 400,
|
||||
`${collapseResidue.shots} 帧采样,窗口带内岛像素峰值=${collapseResidue.max}(该带本该全是底板色)`,
|
||||
collapseResidue.stableShots >= 4 && collapseResidue.max <= 400,
|
||||
`${collapseResidue.shots} 帧采样(稳态 ${collapseResidue.stableShots} 帧),窗口带内岛像素峰值=${collapseResidue.max}(该带本该全是底板色)`,
|
||||
);
|
||||
island.setTouchMode(false);
|
||||
await sleep(200);
|
||||
@@ -3269,8 +3292,8 @@ async function runIslandChecks(
|
||||
await sleep(400);
|
||||
const opacityActual = islandWindow?.getOpacity() ?? -1;
|
||||
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)}`,
|
||||
);
|
||||
|
||||
|
||||
@@ -21,6 +21,11 @@ import type { IslandNotification, UpdateInfo } from '@classhelper/shared';
|
||||
*/
|
||||
const bridge: DesktopBridge = {
|
||||
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',
|
||||
// 冒烟凭据:只在冒烟模式下透传,供渲染进程的冒烟钩子构造测试数据用。
|
||||
// **不走代码字面量**——本文件与渲染产物都会进 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,供主进程冒烟脚本调用 */
|
||||
export function registerSmokeHooks(): void {
|
||||
window.__classhelperSmoke__ = {
|
||||
islandEditorSmoke,
|
||||
islandScheduleSyncSelfTest,
|
||||
cacheSelfTest,
|
||||
cacheIsolationSelfTest,
|
||||
|
||||
@@ -20,6 +20,10 @@ const auth = useAuthStore();
|
||||
const realtime = useRealtimeStore();
|
||||
const notifications = useNotificationStore();
|
||||
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'),
|
||||
meta: { title: '灵动岛' },
|
||||
},
|
||||
{
|
||||
path: 'settings/island/editor',
|
||||
name: 'settings-island-editor',
|
||||
component: () => import('../views/settings/IslandEditorView.vue'),
|
||||
meta: { title: '自定义灵动岛' },
|
||||
},
|
||||
{
|
||||
path: '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 { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useRouter } from 'vue-router';
|
||||
import {
|
||||
DEFAULT_ISLAND_APPEARANCE,
|
||||
ISLAND_APPEARANCE_RANGES as RANGES,
|
||||
@@ -23,9 +24,10 @@ import {
|
||||
*/
|
||||
const island = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
|
||||
const savingIsland = ref(false);
|
||||
const router = useRouter();
|
||||
function editLayout() {
|
||||
layoutMode.value = 'custom';
|
||||
void window.desktop?.islandEditLayout();
|
||||
void router.push('/settings/island/editor');
|
||||
}
|
||||
const layoutMode = ref<'fixed' | 'custom'>('fixed');
|
||||
const residentEnabled = ref(false);
|
||||
@@ -161,7 +163,7 @@ onMounted(async () => {
|
||||
@click="chooseLayout('fixed')"
|
||||
>
|
||||
<strong>经典灵动岛</strong>
|
||||
<span>有消息时展开,保持熟悉的提醒样式</span>
|
||||
<span>显示课程进度、上下课提醒与班级消息</span>
|
||||
</button>
|
||||
<button
|
||||
class="layout-choice"
|
||||
@@ -208,7 +210,7 @@ onMounted(async () => {
|
||||
</template>
|
||||
<p class="text-muted">紧急消息立即展开;课中普通通知暂存,下课后展开。自定义岛可点击铃铛主动查看。</p>
|
||||
<p class="text-muted">
|
||||
上课前 60 秒显示准备提醒和倒计时,上下课时提示课程状态;课程时间显示在名称右侧。
|
||||
课前提示与倒计时、课中进度、即将下课倒数及上下课提醒,可在「提醒」页分别设置。课程时间显示在名称右侧。
|
||||
</p>
|
||||
<el-form label-width="110px" label-position="left">
|
||||
<el-form-item :label="`${layoutMode === 'custom' ? '通知高度' : '高度'} ${island.height}px`">
|
||||
|
||||
@@ -2,7 +2,26 @@
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { classGradeQueryApi } from '../../api/index.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 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 端「班级管理」里开关,
|
||||
@@ -34,7 +53,51 @@ onMounted(loadGradeQuery);
|
||||
</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>
|
||||
<p class="text-muted">
|
||||
通知与叫人通过 ClassHelper 客户端和灵动岛提醒。普通通知在上课期间暂存,下课后补弹;紧急通知立即提醒。
|
||||
@@ -62,6 +125,17 @@ onMounted(loadGradeQuery);
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -118,6 +118,7 @@ export interface IslandClassStatePayload {
|
||||
|
||||
export interface DesktopBridge {
|
||||
islandEditLayout(): Promise<void>;
|
||||
onIslandEditorOpen(handler: () => void): () => void;
|
||||
getUpdateProgress(): Promise<ContractReleaseProgressDto>;
|
||||
onUpdateProgress(handler: (progress: ContractReleaseProgressDto) => void): () => void;
|
||||
/** 是否处于冒烟验证模式(由 ELECTRON_SMOKE_TEST=1 触发),同步可读 */
|
||||
@@ -253,6 +254,7 @@ declare global {
|
||||
island?: IslandRendererBridge;
|
||||
/** 冒烟验证钩子,仅在 ELECTRON_SMOKE_TEST=1 时由渲染进程注册 */
|
||||
__classhelperSmoke__?: {
|
||||
islandEditorSmoke(open: boolean): Promise<void>;
|
||||
islandScheduleSyncSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||
cacheSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||
cacheIsolationSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||
|
||||
@@ -71,10 +71,11 @@ export interface IslandComponentNode {
|
||||
template: boolean;
|
||||
hour12: boolean;
|
||||
seconds: boolean;
|
||||
target: 'next' | 'school';
|
||||
target: 'auto' | 'next' | 'end' | 'school';
|
||||
display: 'current' | 'next' | 'today';
|
||||
count: number;
|
||||
showPeriod: boolean;
|
||||
showProgress: boolean;
|
||||
direction: 'horizontal' | 'vertical';
|
||||
gap: number;
|
||||
interval: number;
|
||||
@@ -116,9 +117,29 @@ export interface IslandLayoutSettings {
|
||||
residentEnabled: boolean;
|
||||
pointerFadeEnabled: boolean;
|
||||
pointerFadeReversed: boolean;
|
||||
lessonReminders: LessonReminderSettings;
|
||||
activeSchemeId: string;
|
||||
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[] {
|
||||
const course = islandNode('schedule', 'expanded-schedule');
|
||||
course.settings.display = 'today';
|
||||
@@ -143,10 +164,11 @@ export function islandNode(kind: IslandComponentKind, id: string): IslandCompone
|
||||
template: false,
|
||||
hour12: false,
|
||||
seconds: true,
|
||||
target: 'next',
|
||||
target: 'auto',
|
||||
display: 'current',
|
||||
count: 5,
|
||||
showPeriod: true,
|
||||
showProgress: true,
|
||||
direction: 'horizontal',
|
||||
gap: 8,
|
||||
interval: 5,
|
||||
@@ -177,6 +199,7 @@ export function defaultIslandLayouts(): IslandLayoutSettings {
|
||||
residentEnabled: false,
|
||||
pointerFadeEnabled: true,
|
||||
pointerFadeReversed: false,
|
||||
lessonReminders: { ...DEFAULT_LESSON_REMINDERS },
|
||||
activeSchemeId: 'default',
|
||||
schemes: [
|
||||
{
|
||||
@@ -237,10 +260,11 @@ export function normalizeIslandLayouts(input: unknown): IslandLayoutSettings {
|
||||
template: s.template === true,
|
||||
hour12: s.hour12 === true,
|
||||
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',
|
||||
count: clamp(s.count, 1, 40, 5),
|
||||
showPeriod: s.showPeriod !== false,
|
||||
showProgress: s.showProgress !== false,
|
||||
direction: s.direction === 'vertical' ? 'vertical' : 'horizontal',
|
||||
gap: clamp(s.gap, 0, 64, 8),
|
||||
interval: clamp(s.interval, 1, 300, 5),
|
||||
@@ -331,6 +355,15 @@ export function normalizeIslandLayouts(input: unknown): IslandLayoutSettings {
|
||||
residentEnabled: raw.layout === 'custom',
|
||||
pointerFadeEnabled: raw.pointerFadeEnabled !== false,
|
||||
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,
|
||||
activeSchemeId: schemes.some((s) => s.id === raw.activeSchemeId)
|
||||
? raw.activeSchemeId
|
||||
|
||||
@@ -1073,7 +1073,7 @@ export type IslandNotificationKind = 'notification' | 'homework' | 'call';
|
||||
/** 本机课表产生的临时提醒,不进入教师通知队列或已读记录。 */
|
||||
export interface IslandLessonReminder {
|
||||
id: string;
|
||||
kind: 'prepare' | 'on-class' | 'break';
|
||||
kind: 'prepare' | 'on-class' | 'break' | 'ending' | 'in-class';
|
||||
courseName: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
@@ -1081,6 +1081,7 @@ export interface IslandLessonReminder {
|
||||
maskUntil: number;
|
||||
endsAt: number;
|
||||
lessonStartsAt: number;
|
||||
lessonEndsAt?: number;
|
||||
nextCourseName?: string;
|
||||
nextStartTime?: 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">
|
||||
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 { 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 ManagementMenu from '@/components/ManagementMenu.vue';
|
||||
import { MANAGEMENT_GROUPS, type ManagementMenuItem } from '@/config/navigation';
|
||||
import UpdateCheckDialog from '@/components/UpdateCheckDialog.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useRealtimeStore } from '@/stores/realtime';
|
||||
@@ -87,90 +89,72 @@ function openAssistant(): void {
|
||||
* (`permission:manage` / `client:manage`)—— 教师被某个权限组授予它之后同样该看见
|
||||
* (见 `visibleMenuItems`)
|
||||
*/
|
||||
interface MenuItem {
|
||||
path: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
roles: UserRole[];
|
||||
/** 可选的**生效权限**门控:有值时还要 `auth.can()` 通过才显示(角色判不出来这一层) */
|
||||
permission?: PermissionKey;
|
||||
}
|
||||
|
||||
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 menuItems = router
|
||||
.getRoutes()
|
||||
.filter((record) => MANAGEMENT_GROUPS.some((g) => (g.paths as readonly string[]).includes(record.path)))
|
||||
.map((record) => ({
|
||||
path: record.path,
|
||||
title: String(record.meta.title || record.path),
|
||||
icon: String(record.meta.icon || 'Setting'),
|
||||
roles: record.meta.roles as string[] | undefined,
|
||||
permission: record.meta.permission as PermissionKey | undefined,
|
||||
}));
|
||||
const visibleMenuItems = computed(() =>
|
||||
menuItems.filter((item) => {
|
||||
if (!auth.role || !item.roles.includes(auth.role)) return false;
|
||||
return !item.permission || auth.can(item.permission);
|
||||
}),
|
||||
menuItems.filter(
|
||||
(item) =>
|
||||
!!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);
|
||||
@@ -178,172 +162,10 @@ const activeMenu = computed(() => route.path);
|
||||
/** 顶栏显示的当前页面标题(小屏下替代侧边栏的"我在哪"提示,1Panel 同款做法) */
|
||||
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(() => {
|
||||
// 生效权限(`GET /permissions/me`):菜单里的「权限组」要等它回来才显示。
|
||||
// 失败不阻塞布局,带权限要求的入口保持关闭。
|
||||
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);
|
||||
|
||||
@@ -356,11 +178,6 @@ watch(
|
||||
|
||||
watch(isMobile, (mobile) => {
|
||||
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) -->
|
||||
<el-aside
|
||||
v-if="!isMobile"
|
||||
ref="asideRef"
|
||||
:width="ui.sidebarCollapsed ? 'var(--ch-aside-collapsed-width)' : 'var(--ch-aside-width)'"
|
||||
class="layout-aside ch-sidebar ch-nav"
|
||||
:class="{ 'is-collapsed': ui.sidebarCollapsed }"
|
||||
@@ -554,23 +370,17 @@ const tourSteps = computed(() => [
|
||||
<img class="brand-logo" src="/logo.png" alt="" width="24" height="24" />
|
||||
<span class="brand-text">ClassHelper</span>
|
||||
</div>
|
||||
<!-- 激活项的滑动指示器:单个药丸在菜单项之间平移,位置由 syncIndicator() 量出来。
|
||||
外层裁剪框贴着**菜单视口**(菜单列可滚动,激活项滚出去时指示器要一起被裁掉),
|
||||
pointer-events:none 是必需的 —— 它盖在菜单项上,否则会把点击吃掉。 -->
|
||||
<div class="nav-indicator-clip" :style="indicatorClipStyle" aria-hidden="true">
|
||||
<div class="nav-indicator" :class="{ 'is-following': indicatorFollowing }" :style="indicatorStyle" />
|
||||
<ManagementMenu
|
||||
:groups="menuGroups"
|
||||
:active="activeMenu"
|
||||
:collapsed="ui.sidebarCollapsed"
|
||||
@select="handleMenuSelect"
|
||||
@expand="ui.toggleSidebar()"
|
||||
/>
|
||||
<div class="sidebar-foot">
|
||||
<span class="sidebar-status-dot" />
|
||||
<span v-if="!ui.sidebarCollapsed">{{ connectionText }}</span>
|
||||
</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-container class="layout-body">
|
||||
@@ -608,7 +418,12 @@ const tourSteps = computed(() => [
|
||||
<Fold v-else />
|
||||
</el-icon>
|
||||
</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">
|
||||
<span class="header-conn">{{ connectionText }}</span>
|
||||
</el-tag>
|
||||
@@ -628,6 +443,11 @@ const tourSteps = computed(() => [
|
||||
</div>
|
||||
|
||||
<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 助手」菜单项同一道门控。
|
||||
关怀模式打开时挂 .care-emphasis(字号写在 styles/index.css 的关怀模式块里,
|
||||
@@ -754,6 +574,35 @@ const tourSteps = computed(() => [
|
||||
</div>
|
||||
</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">
|
||||
<!--
|
||||
这里**刻意不用** `<transition>` 包 router-view。
|
||||
@@ -786,15 +635,33 @@ const tourSteps = computed(() => [
|
||||
<img class="brand-logo" src="/logo.png" alt="" width="24" height="24" />
|
||||
<span class="brand-text">ClassHelper</span>
|
||||
</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">
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
<span>{{ item.title }}</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
<ManagementMenu :groups="menuGroups" :active="activeMenu" @select="handleMenuSelect" />
|
||||
</div>
|
||||
</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-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-width="90px">
|
||||
<el-form-item label="当前密码" prop="currentPassword">
|
||||
@@ -1176,3 +1043,5 @@ const tourSteps = computed(() => [
|
||||
line-height: 1.5;
|
||||
}
|
||||
</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 summary = ref<DashboardSummary | null>(null);
|
||||
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 data = summary.value;
|
||||
@@ -100,6 +126,26 @@ function openNotification(item: NotificationDto): void {
|
||||
</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">
|
||||
<StatCard
|
||||
v-for="(card, index) in statCards"
|
||||
@@ -164,6 +210,113 @@ function openNotification(item: NotificationDto): void {
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -507,7 +507,7 @@ onUnmounted(() => {
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">作业发布</h2>
|
||||
<p class="page-subtitle">发布后立即通过 WebSocket 广播到学生客户端(homework:new)</p>
|
||||
<p class="page-subtitle">按班级和课程发布作业,实时送达教室并查看完成情况</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<el-select
|
||||
|
||||
@@ -493,6 +493,45 @@ async function main() {
|
||||
`出现=${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. 逐一点击侧边栏菜单
|
||||
const visited = [];
|
||||
const failures = [];
|
||||
|
||||
Reference in new issue
Block a user