feat(ui): 补齐课程提醒进度并内嵌编辑器与重构后台导航

This commit is contained in:
LoveChengke committed 2026-10-17 21:21:28 +08:00
1 parent 3379eb975c
commit 536379036a
34 files changed
+1559 -455

No files matched your search

@@ -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;
+31 -45
View File
@@ -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'
? 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),
kind === 'ending' || kind === 'in-class'
? entered
: kind === 'prepare'
? Math.min(start, entered + 3000)
: kind === 'break' && next
? entered + 2000
: entered + 5000,
endsAt:
kind === 'ending' || kind === 'in-class'
? end
: kind === 'prepare'
? start
: kind === 'on-class'
? start + 5000
: end + (next ? 12_000 : 5000),
lessonStartsAt: start,
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(
`(() => { const b=[...document.querySelectorAll('button')].find(b=>b.textContent.includes('在下方新建'));b?.click();return Boolean(document.querySelector('.layout-editor')); })()`,
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() });
+30 -7
View File
@@ -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,写死账号等于随安装包把教师口令发出去。
+39
View File
@@ -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;
+2
View File
@@ -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 }>;